diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3c54133 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Build image + run: docker build -t nvidia-stock-bot:ci . + + - name: Smoke test (syntax check) + run: | + docker run --rm --entrypoint python nvidia-stock-bot:ci -c " + import ast, glob + for f in glob.glob('/app/*.py'): + ast.parse(open(f).read(), filename=f) + print('syntax OK') + " + + - name: Build test image + run: docker build --target test -t nvidia-stock-bot:test . + + - name: Run unit tests + run: docker run --rm nvidia-stock-bot:test pytest -v + + - name: Check deprecation warnings + run: | + docker run --rm --entrypoint python \ + -e DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/123456789012345678/abcdef" \ + -e PRODUCT_NAMES="RTX 5090 Founders Edition" \ + -e TEST_MODE=True \ + nvidia-stock-bot:ci -W error::DeprecationWarning -c "import main" + + - name: Trivy scan (critical, blocking) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 --add-host=dockerhost:host-gateway \ + aquasec/trivy image --severity CRITICAL --exit-code 1 --ignore-unfixed nvidia-stock-bot:ci + + - name: Trivy scan (high, informative) + run: | + docker run --rm \ + -e DOCKER_HOST=tcp://dockerhost:2375 --add-host=dockerhost:host-gateway \ + aquasec/trivy image --severity HIGH --exit-code 0 --ignore-unfixed nvidia-stock-bot:ci + + - name: Version bump, publish image and create release + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + CI_PUSH_TOKEN: ${{ secrets.CI_PUSH_TOKEN }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + IMAGE="git.djeex.fr/djeex/nvidia-stock-bot" + REPO_API="https://git.djeex.fr/api/v1/repos/Djeex/nvidia-stock-bot" + REPO_URL="https://git.djeex.fr/Djeex/nvidia-stock-bot" + + # actions/checkout with persist-credentials:false shouldn't leave a + # credential header behind, but unset it defensively anyway so it + # can never silently shadow the token used for the push below. + git config --unset-all "http.${REPO_URL}.git/.extraheader" || true + git config --unset-all "http.${REPO_URL}/.extraheader" || true + + BASE_SHA="${{ github.event.before }}" + if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA" 2>/dev/null; then + BASE_SHA="HEAD~1" + fi + CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "${{ github.sha }}" 2>/dev/null || git diff --name-only HEAD~1 HEAD 2>/dev/null || true) + echo "Changed files:" + echo "$CHANGED_FILES" + + RELEVANT=false + if echo "$CHANGED_FILES" | grep -qE '^(Dockerfile|VERSION)$|^app/'; then + RELEVANT=true + fi + + VERSION_CHANGED=false + if echo "$CHANGED_FILES" | grep -qx "VERSION"; then + VERSION_CHANGED=true + fi + + CURRENT_VERSION=$(cat VERSION) + + if [ "$RELEVANT" = "true" ] && [ "$VERSION_CHANGED" = "false" ]; then + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + echo "$NEW_VERSION" > VERSION + git config user.name "gitea-ci-bot" + git config user.email "ci-bot@git.djeex.fr" + git add VERSION + git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]" + git push "https://djeex:${CI_PUSH_TOKEN}@git.djeex.fr/Djeex/nvidia-stock-bot.git" "HEAD:main" + else + NEW_VERSION="$CURRENT_VERSION" + fi + + IFS='.' read -r MAJOR MINOR PATCH <<< "$NEW_VERSION" + + docker tag nvidia-stock-bot:ci "${IMAGE}:latest" + docker tag nvidia-stock-bot:ci "${IMAGE}:${MAJOR}.${MINOR}" + docker tag nvidia-stock-bot:ci "${IMAGE}:${NEW_VERSION}" + + echo "$REGISTRY_TOKEN" | docker login git.djeex.fr -u djeex --password-stdin + docker push "${IMAGE}:latest" + docker push "${IMAGE}:${MAJOR}.${MINOR}" + docker push "${IMAGE}:${NEW_VERSION}" + + PR_NUMBER=$(git log -1 --format=%B | grep -oE '#[0-9]+' | head -1 | tr -d '#' || true) + + PR_TITLE="" + LABELS="" + if [ -n "$PR_NUMBER" ]; then + PR_JSON=$(curl -s -H "Authorization: token ${CI_PUSH_TOKEN}" "${REPO_API}/pulls/${PR_NUMBER}") + PR_TITLE=$(echo "$PR_JSON" | jq -r '.title // empty') + LABELS=$(echo "$PR_JSON" | jq -r '[.labels[]?.name] | join(",")') + fi + + CATEGORY="🔧 Maintenance" + if echo "$LABELS" | grep -qw "bug"; then + CATEGORY="⚠️ Hotfix" + elif echo "$LABELS" | grep -qw "major"; then + CATEGORY="💥 Breaking change" + elif echo "$LABELS" | grep -qw "minor"; then + CATEGORY="✨ Update" + fi + + BODY="### ${CATEGORY}"$'\n\n' + if [ -n "$PR_NUMBER" ]; then + BODY="${BODY}**PR:** [#${PR_NUMBER}](${REPO_URL}/pulls/${PR_NUMBER}) — ${PR_TITLE}"$'\n' + fi + BODY="${BODY}**Commit:** [${GITHUB_SHA:0:7}](${REPO_URL}/commit/${GITHUB_SHA})" + + curl -s -X POST \ + -H "Authorization: token ${CI_PUSH_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg tag "$NEW_VERSION" --arg name "$NEW_VERSION" --arg body "$BODY" '{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')" \ + "${REPO_API}/releases" diff --git a/.gitignore b/.gitignore index 7cd6f5d..2d61efc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .venv -__pycache__/ \ No newline at end of file +__pycache__/ +.coverage +.pytest_cache/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 501bbfc..137d62e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-alpine +FROM python:3.13.15-alpine AS base RUN apk add --no-cache ca-certificates @@ -9,4 +9,15 @@ COPY /app/ /app/ RUN pip install --no-cache-dir -r requirements.txt -CMD ["python", "main.py"] \ No newline at end of file +FROM base AS test + +RUN pip install --no-cache-dir pytest==9.1.1 + +COPY pytest.ini /app/pytest.ini +COPY /tests/ /app/tests/ + +CMD ["pytest", "-v"] + +FROM base + +CMD ["python", "main.py"] diff --git a/app/requirements.txt b/app/requirements.txt index 663bd1f..a258782 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1 +1 @@ -requests \ No newline at end of file +requests==2.34.2 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eea2c18 --- /dev/null +++ b/pytest.ini @@ -0,0 +1 @@ +[pytest] diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..cd8bc4c --- /dev/null +++ b/renovate.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "timezone": "Europe/Paris", + "labels": ["bot"], + "vulnerabilityAlerts": { + "addLabels": ["bug"] + }, + "packageRules": [ + { + "matchManagers": ["pip_requirements"], + "matchUpdateTypes": ["patch", "minor"], + "automerge": true + }, + { + "matchManagers": ["dockerfile"], + "matchUpdateTypes": ["patch"], + "automerge": true + }, + { + "matchUpdateTypes": ["major"], + "addLabels": ["major"] + }, + { + "matchUpdateTypes": ["minor"], + "addLabels": ["minor"] + } + ] +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6e7ba78 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import os +import sys + +_TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.dirname(_TESTS_DIR) + +# app/*.py open their JSON config files with plain relative paths, so the +# process cwd must be the directory the app modules live in — matches how +# the Dockerfile runs them (WORKDIR /app). +_APP_DIR = os.path.join(_REPO_ROOT, "app") +if not os.path.isfile(os.path.join(_APP_DIR, "gpu_checker.py")): + _APP_DIR = _REPO_ROOT # container test stage: code copied flat next to tests/ + +if _APP_DIR not in sys.path: + sys.path.insert(0, _APP_DIR) + +os.chdir(_APP_DIR) diff --git a/tests/test_env_config.py b/tests/test_env_config.py new file mode 100644 index 0000000..c2100a6 --- /dev/null +++ b/tests/test_env_config.py @@ -0,0 +1,99 @@ +import importlib +import sys + +import pytest + +MODULE_NAME = "env_config" + +VALID_WEBHOOK = "https://discord.com/api/webhooks/123456789012345678/abcdef" + +ENV_KEYS = [ + "DISCORD_WEBHOOK_URL", + "DISCORD_SERVER_NAME", + "DISCORD_ROLES", + "COUNTRY", + "REFRESH_TIME", + "TEST_MODE", + "PRODUCT_NAMES", +] + + +def _reload_env_config(monkeypatch, env): + for key in ENV_KEYS: + monkeypatch.delenv(key, raising=False) + for key, value in env.items(): + monkeypatch.setenv(key, value) + sys.modules.pop(MODULE_NAME, None) + return importlib.import_module(MODULE_NAME) + + +def test_missing_webhook_exits(monkeypatch): + with pytest.raises(SystemExit): + _reload_env_config(monkeypatch, {"PRODUCT_NAMES": "RTX 5090"}) + + +def test_missing_product_names_exits(monkeypatch): + with pytest.raises(SystemExit): + _reload_env_config(monkeypatch, {"DISCORD_WEBHOOK_URL": VALID_WEBHOOK}) + + +def test_default_role_map_is_everyone(monkeypatch): + cfg = _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090, RTX 5080", + }) + assert cfg.DISCORD_ROLE_MAP == {"RTX 5090": "@everyone", "RTX 5080": "@everyone"} + + +def test_role_count_mismatch_exits(monkeypatch): + with pytest.raises(SystemExit): + _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090, RTX 5080", + "DISCORD_ROLES": "<@&123456789012345678>", + }) + + +def test_invalid_role_format_exits(monkeypatch): + with pytest.raises(SystemExit): + _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090", + "DISCORD_ROLES": "not-a-role", + }) + + +def test_valid_role_format_accepted(monkeypatch): + cfg = _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090", + "DISCORD_ROLES": "<@&123456789012345678>", + }) + assert cfg.DISCORD_ROLE_MAP["RTX 5090"] == "<@&123456789012345678>" + + +def test_unknown_country_falls_back_to_us(monkeypatch): + cfg = _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090", + "COUNTRY": "ZZ", + }) + assert cfg.currency == "$" + + +def test_known_country_currency(monkeypatch): + cfg = _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090", + "COUNTRY": "GB", + }) + assert cfg.currency == "£" + + +def test_refresh_time_invalid_exits(monkeypatch): + with pytest.raises(SystemExit): + _reload_env_config(monkeypatch, { + "DISCORD_WEBHOOK_URL": VALID_WEBHOOK, + "PRODUCT_NAMES": "RTX 5090", + "REFRESH_TIME": "not-a-number", + }) diff --git a/tests/test_gpu_checker.py b/tests/test_gpu_checker.py new file mode 100644 index 0000000..51258b9 --- /dev/null +++ b/tests/test_gpu_checker.py @@ -0,0 +1,146 @@ +import importlib +import sys + +VALID_WEBHOOK = "https://discord.com/api/webhooks/123456789012345678/abcdef" +PRODUCT_NAME = "RTX 5090 Founders Edition" + +SKU_PAYLOAD = { + "searchedProducts": { + "productDetails": [ + {"gpu": PRODUCT_NAME, "productSKU": "SKU-1", "productUPC": "ABC123"} + ] + } +} + + +class FakeResponse: + def __init__(self, payload, status_code=200): + self._payload = payload + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise Exception(f"HTTP {self.status_code}") + + def json(self): + return self._payload + + +def _stock_payload(in_stock, price="1999"): + return { + "listMap": [ + {"fe_sku": "ABC123", "is_active": "true" if in_stock else "false", "price": price} + ] + } + + +def _import_gpu_checker(monkeypatch): + monkeypatch.setenv("DISCORD_WEBHOOK_URL", VALID_WEBHOOK) + monkeypatch.setenv("PRODUCT_NAMES", PRODUCT_NAME) + monkeypatch.setenv("TEST_MODE", "True") + monkeypatch.delenv("DISCORD_ROLES", raising=False) + for mod in ("env_config", "notifier", "gpu_checker"): + sys.modules.pop(mod, None) + return importlib.import_module("gpu_checker") + + +def _queue_responses(monkeypatch, checker, *payloads): + responses = [FakeResponse(p) for p in payloads] + monkeypatch.setattr(checker.session, "get", lambda *a, **k: responses.pop(0)) + + +def test_transition_to_in_stock_sends_notification(monkeypatch): + checker = _import_gpu_checker(monkeypatch) + calls = [] + monkeypatch.setattr(checker, "send_discord_notification", lambda *a: calls.append(("in_stock", a))) + monkeypatch.setattr(checker, "send_out_of_stock_notification", lambda *a: calls.append(("out_of_stock", a))) + monkeypatch.setattr(checker, "send_sku_change_notification", lambda *a: calls.append(("sku_change", a))) + + _queue_responses(monkeypatch, checker, SKU_PAYLOAD, _stock_payload(True)) + checker.check_rtx_50_founders() + + assert len(calls) == 1 + assert calls[0][0] == "in_stock" + assert calls[0][1][0] == PRODUCT_NAME + + +def test_transition_to_out_of_stock_sends_notification(monkeypatch): + checker = _import_gpu_checker(monkeypatch) + calls = [] + monkeypatch.setattr(checker, "send_discord_notification", lambda *a: calls.append(("in_stock", a))) + monkeypatch.setattr(checker, "send_out_of_stock_notification", lambda *a: calls.append(("out_of_stock", a))) + monkeypatch.setattr(checker, "send_sku_change_notification", lambda *a: calls.append(("sku_change", a))) + + _queue_responses(monkeypatch, checker, SKU_PAYLOAD, _stock_payload(True)) + checker.check_rtx_50_founders() + + _queue_responses(monkeypatch, checker, SKU_PAYLOAD, _stock_payload(False)) + checker.check_rtx_50_founders() + + assert calls[-1][0] == "out_of_stock" + assert calls[-1][1][0] == PRODUCT_NAME + + +def test_no_duplicate_notification_while_still_in_stock(monkeypatch): + checker = _import_gpu_checker(monkeypatch) + calls = [] + monkeypatch.setattr(checker, "send_discord_notification", lambda *a: calls.append(("in_stock", a))) + monkeypatch.setattr(checker, "send_out_of_stock_notification", lambda *a: calls.append(("out_of_stock", a))) + monkeypatch.setattr(checker, "send_sku_change_notification", lambda *a: calls.append(("sku_change", a))) + + _queue_responses(monkeypatch, checker, SKU_PAYLOAD, _stock_payload(True)) + checker.check_rtx_50_founders() + _queue_responses(monkeypatch, checker, SKU_PAYLOAD, _stock_payload(True)) + checker.check_rtx_50_founders() + + assert len(calls) == 1 + + +def test_sku_change_triggers_notification_after_first_run(monkeypatch): + checker = _import_gpu_checker(monkeypatch) + sku_change_calls = [] + monkeypatch.setattr(checker, "send_discord_notification", lambda *a: None) + monkeypatch.setattr(checker, "send_out_of_stock_notification", lambda *a: None) + monkeypatch.setattr(checker, "send_sku_change_notification", lambda *a: sku_change_calls.append(a)) + + _queue_responses(monkeypatch, checker, SKU_PAYLOAD, _stock_payload(False)) + checker.check_rtx_50_founders() + assert sku_change_calls == [] # first run must never fire a "change" notification + + changed_payload = { + "searchedProducts": { + "productDetails": [ + {"gpu": PRODUCT_NAME, "productSKU": "SKU-2", "productUPC": "ABC123"} + ] + } + } + _queue_responses(monkeypatch, checker, changed_payload, _stock_payload(False)) + checker.check_rtx_50_founders() + + assert len(sku_change_calls) == 1 + assert sku_change_calls[0][1] == "SKU-1" + assert sku_change_calls[0][2] == "SKU-2" + + +def test_missing_product_in_api_is_skipped_gracefully(monkeypatch): + checker = _import_gpu_checker(monkeypatch) + calls = [] + monkeypatch.setattr(checker, "send_discord_notification", lambda *a: calls.append(a)) + + empty_payload = {"searchedProducts": {"productDetails": []}} + monkeypatch.setattr(checker.session, "get", lambda *a, **k: FakeResponse(empty_payload)) + + checker.check_rtx_50_founders() # must not raise, just log a warning and skip + + assert calls == [] + + +def test_sku_api_error_is_handled_without_raising(monkeypatch): + checker = _import_gpu_checker(monkeypatch) + + def raise_error(*a, **k): + raise checker.requests.exceptions.ConnectionError("boom") + + monkeypatch.setattr(checker.session, "get", raise_error) + + checker.check_rtx_50_founders() # must not propagate the network error diff --git a/tests/test_notifier.py b/tests/test_notifier.py new file mode 100644 index 0000000..e8fdf58 --- /dev/null +++ b/tests/test_notifier.py @@ -0,0 +1,163 @@ +import importlib +import sys + +VALID_WEBHOOK = "https://discord.com/api/webhooks/123456789012345678/abcdef" + + +def _import_notifier(monkeypatch, test_mode="False", discord_roles=None): + monkeypatch.setenv("DISCORD_WEBHOOK_URL", VALID_WEBHOOK) + monkeypatch.setenv("PRODUCT_NAMES", "RTX 5090 Founders Edition") + monkeypatch.setenv("TEST_MODE", test_mode) + if discord_roles is not None: + monkeypatch.setenv("DISCORD_ROLES", discord_roles) + else: + monkeypatch.delenv("DISCORD_ROLES", raising=False) + for mod in ("env_config", "notifier"): + sys.modules.pop(mod, None) + return importlib.import_module("notifier") + + +class FakeResponse: + def __init__(self, status_code=204, text=""): + self.status_code = status_code + self.text = text + + +def test_test_mode_skips_network_call(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="True") + calls = [] + monkeypatch.setattr(notifier.requests, "post", lambda *a, **k: calls.append((a, k))) + + notifier.send_discord_notification("RTX 5090 Founders Edition", "https://example.com", "1999") + + assert calls == [] + + +def test_in_stock_notification_posts_expected_payload(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + captured = {} + + def fake_post(url, json=None, **kwargs): + captured["url"] = url + captured["json"] = json + return FakeResponse() + + monkeypatch.setattr(notifier.requests, "post", fake_post) + + notifier.send_discord_notification("RTX 5090 Founders Edition", "https://example.com/buy", "1999") + + assert captured["url"] == notifier.DISCORD_WEBHOOK_URL + assert captured["json"]["content"] == "@everyone" + embed = captured["json"]["embeds"][0] + assert "RTX 5090 Founders Edition" in embed["title"] + + +def test_discord_notification_survives_http_error(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + monkeypatch.setattr(notifier.requests, "post", lambda *a, **k: FakeResponse(status_code=500, text="boom")) + + notifier.send_discord_notification("RTX 5090 Founders Edition", "https://example.com", "1999") + + +def test_discord_notification_survives_connection_error(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + + def raise_error(*a, **k): + raise notifier.requests.exceptions.ConnectionError("boom") + + monkeypatch.setattr(notifier.requests, "post", raise_error) + + # Should not raise even though the request itself blew up (network down, DNS, etc.) + notifier.send_discord_notification("RTX 5090 Founders Edition", "https://example.com", "1999") + + +def test_out_of_stock_test_mode_skips_network_call(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="True") + calls = [] + monkeypatch.setattr(notifier.requests, "post", lambda *a, **k: calls.append((a, k))) + + notifier.send_out_of_stock_notification("RTX 5090 Founders Edition", "https://example.com", "1999") + + assert calls == [] + + +def test_out_of_stock_notification_posts_on_success(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + captured = {} + + def fake_post(url, json=None, **kwargs): + captured["json"] = json + return FakeResponse(status_code=204) + + monkeypatch.setattr(notifier.requests, "post", fake_post) + + notifier.send_out_of_stock_notification("RTX 5090 Founders Edition", "https://example.com/buy", "1999") + + assert captured["json"]["embeds"][0]["url"] == "https://example.com/buy" + + +def test_out_of_stock_notification_survives_http_error(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + monkeypatch.setattr(notifier.requests, "post", lambda *a, **k: FakeResponse(status_code=500, text="boom")) + + # Should not raise even though the webhook call "fails" + notifier.send_out_of_stock_notification("RTX 5090 Founders Edition", "https://example.com", "1999") + + +def test_out_of_stock_notification_survives_connection_error(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + + def raise_error(*a, **k): + raise notifier.requests.exceptions.ConnectionError("boom") + + monkeypatch.setattr(notifier.requests, "post", raise_error) + + notifier.send_out_of_stock_notification("RTX 5090 Founders Edition", "https://example.com", "1999") + + +def test_sku_change_test_mode_skips_network_call(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="True") + calls = [] + monkeypatch.setattr(notifier.requests, "post", lambda *a, **k: calls.append((a, k))) + + notifier.send_sku_change_notification("RTX 5090 Founders Edition", "old-sku", "new-sku", "https://example.com") + + assert calls == [] + + +def test_sku_change_notification_survives_http_error(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + monkeypatch.setattr(notifier.requests, "post", lambda *a, **k: FakeResponse(status_code=500, text="boom")) + + notifier.send_sku_change_notification("RTX 5090 Founders Edition", "old-sku", "new-sku", "https://example.com") + + +def test_sku_change_notification_survives_connection_error(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False") + + def raise_error(*a, **k): + raise notifier.requests.exceptions.ConnectionError("boom") + + monkeypatch.setattr(notifier.requests, "post", raise_error) + + notifier.send_sku_change_notification("RTX 5090 Founders Edition", "old-sku", "new-sku", "https://example.com") + + +def test_sku_change_notification_mentions_role_and_skus(monkeypatch): + notifier = _import_notifier(monkeypatch, test_mode="False", discord_roles="<@&123456789012345678>") + captured = {} + + def fake_post(url, json=None, **kwargs): + captured["json"] = json + return FakeResponse() + + monkeypatch.setattr(notifier.requests, "post", fake_post) + + notifier.send_sku_change_notification( + "RTX 5090 Founders Edition", "old-sku-123", "new-sku-456", "https://example.com" + ) + + assert "<@&123456789012345678>" in captured["json"]["content"] + description = captured["json"]["embeds"][0]["description"] + assert "old-sku-123" in description + assert "new-sku-456" in description