Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ RESEND_API_KEY="your_resend_api_key"
EMAIL_ID="your_email_id"
EMAIL_MIN_INTERVAL_SECONDS="5.0" # Rate limiting between emails

# Bot Webhook Notification
# VoucherBot POSTs the same voucher data it sends by email to this endpoint,
# authenticated with WEBHOOK_SECRET sent as an Authorization header.
NOTIFICATION_BOT_SERVER_URL="https://your-bot-server.example.com/webhook"
WEBHOOK_SECRET="your_webhook_secret"

# AI Module
# See docs/setup.md for how to get your Gemini and Groq API keys.
GEMINI_API_KEY="your_gemini_api_key"
Expand Down
7 changes: 7 additions & 0 deletions docs/details/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ These values are loaded from `.env` through Pydantic settings.
| `EMAIL_ID` | `None` | Recipient address for voucher notifications |
| `EMAIL_MIN_INTERVAL_SECONDS` | `5.0` | Minimum delay between email sends |

### Bot webhook notification

| Variable | Default | Purpose |
|---|---:|---|
| `NOTIFICATION_BOT_SERVER_URL` | `None` | Endpoint that receives a POST with the same voucher alert data as the email, for a remote bot server |
| `WEBHOOK_SECRET` | `None` | Secret sent in the `Authorization` header of the webhook POST |

### Reddit ingestion

| Variable | Default | Purpose |
Expand Down
121 changes: 108 additions & 13 deletions tests/test_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,22 +166,14 @@ def test_picks_with_weighted_probabilities(self) -> None:
population = list(choices.call_args.args[0])
weights = list(choices.call_args.kwargs["weights"])
assert population == analyzer._GROQ_BATCH_MODELS
assert weights == [40, 40, 20]
assert set(weights) == {0.5}
assert "qwen/qwen3.6-27b" not in population

def test_skips_exhausted_models_when_picking(self) -> None:
_exhaust_daily("llama-3.3-70b-versatile")
with patch(
"voucherbot.services.ai.analyzer.random.choices",
return_value=["openai/gpt-oss-20b"],
) as choices:
picked = analyzer._pick_groq_model()
_exhaust_daily("openai/gpt-oss-20b")
picked = analyzer._pick_groq_model()

assert picked is not None
population = list(choices.call_args.args[0])
assert "llama-3.3-70b-versatile" not in population
assert "llama-3.3-70b-versatile" not in list(
choices.call_args.kwargs["weights"]
)
assert picked == "openai/gpt-oss-120b"


def _exhaust_daily(model: str) -> None:
Expand Down Expand Up @@ -267,6 +259,32 @@ async def test_call_groq_model_returns_parsed_event() -> None:
assert settle_call.args[0] == 1


@pytest.mark.asyncio
async def test_call_groq_model_sets_qwen_params() -> None:
client = _fake_groq_client([_groq_response()])
with (
patch("voucherbot.services.ai.analyzer.settings", _settings()),
patch("voucherbot.services.ai.analyzer.AsyncGroq", return_value=client),
patch(
"voucherbot.services.ai.analyzer._wait_for_groq_budget",
new=AsyncMock(return_value=1),
),
patch("voucherbot.services.ai.analyzer._settle_groq_budget", new=AsyncMock()),
):
result = await analyzer._call_groq_model("Title", "Content", "qwen/qwen3.6-27b")

assert result is not None
call = client.chat.completions.create.await_args
assert call is not None
params = call.kwargs
assert params["reasoning_effort"] == "default"
assert params["reasoning_format"] == "hidden"
assert params["response_format"] == {"type": "json_object"}
assert params["temperature"] == 0.6
assert params["top_p"] == 0.95
assert params["max_completion_tokens"] == 2048


@pytest.mark.asyncio
async def test_call_groq_model_skips_when_daily_exhausted() -> None:
_exhaust_daily("openai/gpt-oss-20b")
Expand Down Expand Up @@ -429,6 +447,83 @@ async def test_call_groq_returns_none_when_all_models_fail() -> None:
assert result is None


# ---------------------------------------------------------------------------
# _maybe_escalate_to_qwen
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_escalate_routes_low_confidence_to_qwen() -> None:
primary = ExtractedEvent(is_voucher=True, confidence=0.4)
refined = ExtractedEvent(is_voucher=True, confidence=0.9, reason="refined")
with (
patch(
"voucherbot.services.ai.analyzer.is_model_available",
side_effect=lambda model: True,
),
patch(
"voucherbot.services.ai.analyzer._call_groq_model",
new=AsyncMock(return_value=refined),
) as call_model,
):
result = await analyzer._maybe_escalate_to_qwen("Title", "Content", primary)

assert result is refined
call = call_model.await_args
assert call is not None
assert call.args[2] == analyzer._GROQ_REASONER_MODEL


@pytest.mark.asyncio
async def test_escalate_keeps_high_confidence_result() -> None:
primary = ExtractedEvent(is_voucher=True, confidence=0.9)
with patch(
"voucherbot.services.ai.analyzer._call_groq_model",
new=AsyncMock(),
) as call_model:
result = await analyzer._maybe_escalate_to_qwen("Title", "Content", primary)

assert result is primary
call_model.assert_not_awaited()


@pytest.mark.asyncio
async def test_escalate_keeps_original_when_qwen_unavailable() -> None:
primary = ExtractedEvent(is_voucher=True, confidence=0.4)
with (
patch(
"voucherbot.services.ai.analyzer.is_model_available",
side_effect=lambda model: False,
),
patch(
"voucherbot.services.ai.analyzer._call_groq_model",
new=AsyncMock(),
) as call_model,
):
result = await analyzer._maybe_escalate_to_qwen("Title", "Content", primary)

assert result is primary
call_model.assert_not_awaited()


@pytest.mark.asyncio
async def test_escalate_keeps_original_when_qwen_fails() -> None:
primary = ExtractedEvent(is_voucher=True, confidence=0.4)
with (
patch(
"voucherbot.services.ai.analyzer.is_model_available",
side_effect=lambda model: True,
),
patch(
"voucherbot.services.ai.analyzer._call_groq_model",
new=AsyncMock(return_value=None),
),
):
result = await analyzer._maybe_escalate_to_qwen("Title", "Content", primary)

assert result is primary


# ---------------------------------------------------------------------------
# _call_gemini
# ---------------------------------------------------------------------------
Expand Down
195 changes: 195 additions & 0 deletions tests/test_bot_notification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Tests for the bot webhook notification service."""

from __future__ import annotations

from types import SimpleNamespace
from typing import Any
from unittest.mock import patch

import httpx
import pytest

from voucherbot.services.ai.schema import ExtractedEvent
from voucherbot.services.bot_notification import notifier


def _post(**kwargs: object) -> Any:
base: dict[str, object] = dict(
id=7,
title="Free AZ-900 this week",
url="https://example.com/posts/1",
summary=None,
content_hash="hash123",
)
base.update(kwargs)
return SimpleNamespace(**base)


def _extracted() -> ExtractedEvent:
return ExtractedEvent(
is_voucher=True,
confidence=0.55,
vendor="microsoft",
promotion_name="AI Skills Fest",
voucher_code="MS-LEARN-50",
discount="50%",
promotion_type="voucher",
certifications=["AZ-900"],
regions=["US"],
start_date="2026-09-01",
end_date="2026-09-30",
reason="Free Microsoft exam voucher.",
)


def _settings(**overrides: object) -> SimpleNamespace:
base = SimpleNamespace(
notification_bot_server_url="https://bot.example.com/webhook",
webhook_secret="super-secret",
)
base.__dict__.update(overrides)
return base


def test_build_voucher_payload_shape() -> None:
payload = notifier.build_voucher_payload(_post(), _extracted())

assert payload["event"] == "voucher_alert"
assert payload["title"] == "Voucher: Microsoft — AI Skills Fest"
assert payload["post"] == "https://example.com/posts/1"
assert payload["claim_url"] == "https://example.com/posts/1"
assert payload["confidence"] == 0.55
assert payload["sent_at"]
assert payload["vendor"] == "microsoft"
assert payload["promotion_name"] == "AI Skills Fest"
assert payload["promotion_type"] == "voucher"
assert payload["certifications"] == ["AZ-900"]
assert payload["voucher_code"] == "MS-LEARN-50"
assert payload["discount"] == "50%"
assert payload["regions"] == ["US"]
assert payload["start_date"] == "2026-09-01"
assert payload["end_date"] == "2026-09-30"


def test_build_voucher_payload_omits_nulls() -> None:
payload = notifier.build_voucher_payload(_post(), ExtractedEvent(is_voucher=True))

assert payload["event"] == "voucher_alert"
assert "vendor" not in payload
assert "promotion_name" not in payload
assert "voucher_code" not in payload
assert "discount" not in payload
assert "certifications" not in payload
assert "discount" not in payload


def test_build_voucher_payload_claim_url_falls_back_to_post() -> None:
extracted = _extracted()
extracted.registration_url = "https://aws.example.com/claim"
payload = notifier.build_voucher_payload(_post(), extracted)

assert payload["claim_url"] == "https://aws.example.com/claim"


@pytest.mark.asyncio
async def test_send_skips_when_not_configured() -> None:
with patch(
"voucherbot.services.bot_notification.notifier.settings",
_settings(notification_bot_server_url=None),
):
result = await notifier.send_bot_notification(_post(), _extracted())
assert result is False


@pytest.mark.asyncio
async def test_send_skips_when_secret_missing() -> None:
with patch(
"voucherbot.services.bot_notification.notifier.settings",
_settings(webhook_secret=None),
):
result = await notifier.send_bot_notification(_post(), _extracted())
assert result is False


@pytest.mark.asyncio
async def test_send_posts_with_auth_header() -> None:
class FakeResponse:
status_code = 200
raise_for_status = lambda self: None # noqa: E731

captured: dict[str, Any] = {}

class FakeClient:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass

async def __aenter__(self) -> "FakeClient":
return self

async def __aexit__(self, *args: Any) -> bool:
return False

async def post(
self, url: str, *, json: Any, headers: dict[str, str]
) -> FakeResponse:
captured["url"] = url
captured["json"] = json
captured["headers"] = headers
return FakeResponse()

with (
patch(
"voucherbot.services.bot_notification.notifier.settings",
_settings(),
),
patch(
"voucherbot.services.bot_notification.notifier.httpx.AsyncClient",
FakeClient,
),
):
result = await notifier.send_bot_notification(_post(), _extracted())

assert result is True
assert captured["url"] == "https://bot.example.com/webhook"
assert captured["headers"] == {
"Authorization": "Bearer super-secret",
"Content-Type": "application/json",
}
assert captured["json"]["event"] == "voucher_alert"
assert captured["json"]["promotion_name"] == "AI Skills Fest"


@pytest.mark.asyncio
async def test_send_returns_false_on_http_error() -> None:
async def _fail_post(*args: Any, **kwargs: Any) -> None:
raise httpx.HTTPStatusError(
"500",
request=httpx.Request("POST", "https://bot.example.com"),
response=httpx.Response(500),
)

class FakeClient:
def __init__(self, *args: Any, **kwargs: Any) -> None:
pass

async def __aenter__(self) -> "FakeClient":
return self

async def __aexit__(self, *args: Any) -> bool:
return False

post = _fail_post

with (
patch(
"voucherbot.services.bot_notification.notifier.settings",
_settings(),
),
patch(
"voucherbot.services.bot_notification.notifier.httpx.AsyncClient",
FakeClient,
),
):
result = await notifier.send_bot_notification(_post(), _extracted())

assert result is False
6 changes: 6 additions & 0 deletions tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ async def test_process_one_source_new_voucher_full_pipeline() -> None:
"voucherbot.services.ingestion.pipeline.deliver_pending_notifications",
new=AsyncMock(return_value=1),
) as deliver,
patch(
"voucherbot.services.ingestion.pipeline.send_bot_notification",
new=AsyncMock(return_value=True),
) as bot_notify,
):
stats = await pipeline._process_one_source(db, source, collector, keywords, 10)

Expand All @@ -332,11 +336,13 @@ async def test_process_one_source_new_voucher_full_pipeline() -> None:
assert stats["ai_analyzed"] == 1
assert stats["events_created"] == 1
assert stats["notified"] == 1
assert stats["bot_notified"] == 1
assert db_post.ai_result == extracted.model_dump()
assert db_post.status == PostStatus.PROCESSED
assert source.error_count == 0
db.commit.assert_awaited()
deliver.assert_awaited_once()
bot_notify.assert_awaited_once()


@pytest.mark.asyncio
Expand Down
Loading
Loading