feat: phase r1+r2 recovery + r3-0 swiftly api ingestion

R1 stabilization: pytest harness with transactional db fixture, smoke
+ alembic + auth + scrape + approval + swiftly tests, github actions
ci yaml. Bearer-token admin auth + signed-cookie session for family
ui mutations. Async POST /api/admin/scrape (BackgroundTasks, returns
202). Path canonicalization (no /list, /planned suffixes). DATABASE_URL
fail-fast on empty.

R2 deferred-risk spikes: live lucky california fetch (R2-A), full
email+per-voter approval click round trip with single-use enforcement
(R2-B, console email backend, sendgrid stub).

R3-0 phase 3 redesign: replaced playwright html scraper with requests
based swiftly json api client. 17 categories, ~10k products per scrape,
upsert by (source, external_id). 401 surfaces actionable token-refresh
message via ScrapeLog.error_message.

Pre-existing defects fixed: shopping_list.py syntax error blocking app
import, MealPlan.votes orphan relationship, JSONB(astext=True) invalid
kwarg, missing requests dep, calorie_target schema drift, every SQLEnum
needed values_callable, 0001 had empty downgrade(), seed had duplicate
ingredient rows.

Migrations added: 0003 grocery_item.description, 0004 family_profile.
calorie_target, 0005 grocery_item.external_id + source + composite index.

Verified: 31/31 pytest green, alembic upgrade->downgrade->upgrade clean,
frontend npm run build clean, live scrape 9,960 grocery_item rows in 36s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 14:08:19 -07:00
co-authored by Claude Opus 4.7
parent b9434967ed
commit 8e89f793d5
58 changed files with 3594 additions and 348 deletions
View File
+205
View File
@@ -0,0 +1,205 @@
"""
Pytest fixtures for MealPlanner backend.
DB strategy
-----------
- If env ``TEST_DATABASE_URL`` is set, use it (must be a Postgres URL — the
current Alembic migrations use ``postgresql.UUID``/``JSONB``/``ARRAY`` types
which are not portable to SQLite).
- Else fall back to ``DATABASE_URL`` if it points at Postgres.
- Else skip Postgres-only tests (marker: ``requires_postgres``).
Each test using the ``db`` fixture runs inside a SAVEPOINT-style nested
transaction that rolls back on teardown so tests do not leak state.
"""
from __future__ import annotations
import os
import sys
import pathlib
import subprocess
from typing import Iterator
import pytest
# Ensure DATABASE_URL is set BEFORE importing app.config (Settings requires it).
# We default to the TEST_DATABASE_URL or a sentinel that lets imports succeed;
# tests that actually need the DB rely on the marker / fixture skip path below.
_DEFAULT_DSN = "postgresql://mealplanner:password@localhost:5432/mealplanner_test"
os.environ.setdefault(
"DATABASE_URL",
os.environ.get("TEST_DATABASE_URL", _DEFAULT_DSN),
)
# Make backend/ importable when pytest is invoked from repo root.
BACKEND_ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from sqlalchemy import create_engine, text # noqa: E402
from sqlalchemy.orm import sessionmaker # noqa: E402
from sqlalchemy.exc import OperationalError # noqa: E402
def _resolve_test_dsn() -> str | None:
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")
if not dsn:
return None
if not dsn.startswith(("postgresql://", "postgresql+psycopg2://")):
return None
return dsn
def _postgres_reachable(dsn: str) -> bool:
try:
eng = create_engine(
dsn.replace("postgresql://", "postgresql+psycopg2://"),
pool_pre_ping=True,
)
with eng.connect() as conn:
conn.execute(text("SELECT 1"))
eng.dispose()
return True
except Exception:
return False
_DSN = _resolve_test_dsn()
_PG_AVAILABLE = bool(_DSN) and _postgres_reachable(_DSN)
def pytest_collection_modifyitems(config, items):
"""Skip postgres-only tests when no live Postgres is available."""
if _PG_AVAILABLE:
return
skip_pg = pytest.mark.skip(
reason="Postgres not reachable; set TEST_DATABASE_URL to enable."
)
for item in items:
if "requires_postgres" in item.keywords:
item.add_marker(skip_pg)
def pytest_configure(config):
config.addinivalue_line(
"markers",
"requires_postgres: test needs a live Postgres reachable via TEST_DATABASE_URL",
)
config.addinivalue_line(
"markers",
"scraper_offline: parser-only test; uses saved HTML fixture; no "
"network or Playwright/Chromium required (CI-safe).",
)
config.addinivalue_line(
"markers",
"scraper_live: hits the live grocery website; requires Playwright "
"and Chromium; skipped by default in CI.",
)
# ---------------------------------------------------------------------------
# Schema bootstrap (session-scoped): run alembic upgrade head once per session.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="session")
def _schema() -> Iterator[None]:
if not _PG_AVAILABLE:
yield
return
env = os.environ.copy()
env["DATABASE_URL"] = _DSN # Alembic env.py reads from settings.DATABASE_URL
# alembic.ini lives in backend/, run from there.
subprocess.run(
["alembic", "upgrade", "head"],
cwd=str(BACKEND_ROOT),
env=env,
check=True,
)
yield
# Best-effort cleanup so re-running the suite locally is idempotent.
subprocess.run(
["alembic", "downgrade", "base"],
cwd=str(BACKEND_ROOT),
env=env,
check=False,
)
@pytest.fixture(scope="session")
def _engine(_schema):
if not _PG_AVAILABLE:
yield None
return
eng = create_engine(
_DSN.replace("postgresql://", "postgresql+psycopg2://"),
pool_pre_ping=True,
)
yield eng
eng.dispose()
@pytest.fixture()
def db(_engine):
"""Per-test transactional session that rolls back at teardown."""
if _engine is None:
pytest.skip("Postgres not reachable")
connection = _engine.connect()
trans = connection.begin()
Session = sessionmaker(bind=connection, autocommit=False, autoflush=False)
session = Session()
try:
yield session
finally:
session.close()
trans.rollback()
connection.close()
@pytest.fixture()
def client(db):
"""TestClient with get_db overridden to yield the test session."""
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db
def _override():
try:
yield db
finally:
pass
app.dependency_overrides[get_db] = _override
try:
with TestClient(app) as c:
yield c
finally:
app.dependency_overrides.pop(get_db, None)
@pytest.fixture()
def client_no_db():
"""TestClient that does NOT require a live DB — for pure import/wiring smoke."""
from fastapi.testclient import TestClient
from app.main import app
from app.database import get_db
class _StubSession:
def execute(self, *a, **kw):
from sqlalchemy.engine import Result # noqa: F401
raise RuntimeError("DB not available in this fixture")
def query(self, *a, **kw):
raise RuntimeError("DB not available in this fixture")
def close(self):
pass
def _override():
yield _StubSession()
app.dependency_overrides[get_db] = _override
try:
with TestClient(app) as c:
yield c
finally:
app.dependency_overrides.pop(get_db, None)
+50
View File
@@ -0,0 +1,50 @@
# Lucky California weekly-ad spike (R2-A)
| Field | Value |
| --- | --- |
| URL | https://luckysupermarkets.com/coupons/Coupon%2Flu-featured-in-ad |
| Final URL | https://luckysupermarkets.com/coupons/Coupon%2Flu-featured-in-ad |
| Fetched (UTC) | 2026-05-04 (single live fetch via scripts/spike_lucky_scrape.py) |
| HTTP status | 200 |
| HTML bytes | 201224 |
| Items parsed (current parser) | 11 |
| Captcha/block signal | benign — page contains an empty `<span id="recaptcha-element">` placeholder, no actual challenge served. Title: "Featured in Ad \| Luckys Supermarket". 13 real `coupon-card-wrapper` cards rendered. |
| Error | none |
| User-Agent | `Mozilla/5.0 (compatible; MealPlannerSpike/0.1; +https://github.com/MealPlanner; spike=R2-A)` |
## Selectors used (verified against this fixture)
- Card root: `div.coupon-card-wrapper` (fallback: `div.coupon-card-container`)
- Price + short name: `.coupon-card-value-text` (e.g. `"$13.97 Pepsi 24 packs"`)
- Long description: `.coupon-card-short-description`
- Image: `img` inside `.coupon-card-img-container` (CDN URL: `cdn.luckysupermarkets.com/loyalty/offer/<id>.jpg`)
- No per-card link is present in the rendered DOM (offers are non-navigable tiles).
## First parsed item (sample)
```json
{
"name": "Pepsi 24 packs",
"description": "$13.97 Pepsi Products 24 pack, Poppi 8 pack, Gatorade 18 pack, Rockstar 10 pack, or Pure Leaf 12 pack, select varieties +CRV in CA. While supplies last.",
"current_price": 13.97,
"image_url": "https://cdn.luckysupermarkets.com/loyalty/offer/205213.jpg",
"product_url": null,
"is_on_sale": true,
"scraped_at": "2026-05-04T21:42:56.952936",
"scraped_url": "https://luckysupermarkets.com"
}
```
## Notes
- ONE live fetch performed by `scripts/spike_lucky_scrape.py`. Do not rerun without reason.
- HTML and PNG saved alongside this file (`weekly_ad.html`, `weekly_ad.png`).
- Initial parser (regex-based on `h2/h3/a` text) produced only 1 item; selectors were
stale. A minimal additive fix landed in `backend/app/scraper/lucky_ca_scraper.py`:
new `_parse_coupon_card` + `parse_featured_coupons_html` methods that target
Swiftly-style `.coupon-card-wrapper` cards. Old `_parse_coupon_item` retained
as a fallback path.
- Schema impact: `grocery_item` columns (name, current_price, image_url, is_on_sale,
scraped_at, scraped_url) are all populated. `product_url` is None for every
card (no per-offer link in DOM) — keep nullable. New optional `description`
field is produced; either add a `description TEXT` column or drop it.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

+47
View File
@@ -0,0 +1,47 @@
"""
Alembic round-trip test.
Verifies that ``alembic upgrade head`` followed by ``alembic downgrade base``
runs without error against a Postgres throwaway database. Skipped when no
Postgres is reachable (the migrations rely on PG-specific types and cannot
target SQLite).
"""
from __future__ import annotations
import os
import pathlib
import subprocess
import pytest
BACKEND_ROOT = pathlib.Path(__file__).resolve().parent.parent
@pytest.mark.requires_postgres
def test_alembic_upgrade_head_roundtrip():
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL")
assert dsn, "TEST_DATABASE_URL or DATABASE_URL must be set"
env = os.environ.copy()
env["DATABASE_URL"] = dsn
# The session fixture has already upgraded; downgrade then re-upgrade to
# exercise both paths inside this test without polluting the rest of the
# session schema state.
down = subprocess.run(
["alembic", "downgrade", "base"],
cwd=str(BACKEND_ROOT),
env=env,
capture_output=True,
text=True,
)
assert down.returncode == 0, f"downgrade failed: {down.stderr}"
up = subprocess.run(
["alembic", "upgrade", "head"],
cwd=str(BACKEND_ROOT),
env=env,
capture_output=True,
text=True,
)
assert up.returncode == 0, f"upgrade failed: {up.stderr}"
+237
View File
@@ -0,0 +1,237 @@
"""
R2-B: approval-token + per-voter vote round-trip tests.
- Pure unit tests for `app.services.approval` (no DB).
- DB-backed tests for `consume_token` single-use + the two vote routes.
"""
from __future__ import annotations
import time
import uuid
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from app.services import approval as approval_service
# ---------------------------------------------------------------------------
# Pure-unit token tests.
# ---------------------------------------------------------------------------
def test_issue_and_verify_token_roundtrip():
item_id = uuid.uuid4()
voter_id = uuid.uuid4()
token = approval_service.issue_token(item_id, voter_id)
payload = approval_service.verify_token(token)
assert payload["item"] == str(item_id)
assert payload["voter"] == str(voter_id)
def test_verify_rejects_tampered_token():
item_id = uuid.uuid4()
voter_id = uuid.uuid4()
token = approval_service.issue_token(item_id, voter_id)
tampered = token[:-2] + ("AA" if not token.endswith("AA") else "BB")
with pytest.raises(HTTPException) as excinfo:
approval_service.verify_token(tampered)
assert excinfo.value.status_code == 401
def test_verify_rejects_expired_token(monkeypatch):
"""itsdangerous reads `time.time()` — patch the time module's `time`
attribute on `itsdangerous.timed` to simulate clock advance.
"""
import itsdangerous.timed as _timed_mod
item_id = uuid.uuid4()
voter_id = uuid.uuid4()
# Issue at "now".
token = approval_service.issue_token(item_id, voter_id)
# Advance the clock 8 days past issue time (default TTL is 7 days).
real_now = time.time()
fake_now = real_now + (8 * 24 * 3600)
class _FakeTime:
@staticmethod
def time():
return fake_now
monkeypatch.setattr(_timed_mod, "time", _FakeTime)
with pytest.raises(HTTPException) as excinfo:
approval_service.verify_token(token)
assert excinfo.value.status_code == 401
# ---------------------------------------------------------------------------
# DB-backed fixtures: build a minimal scenario reused by several tests.
# ---------------------------------------------------------------------------
@pytest.fixture()
def scenario(db):
"""Create profile + 2 voters + recipe + plan + item, all in one shot.
Uses the per-test transactional session, so changes roll back on
teardown (no cross-test pollution).
"""
from app.models import (
FamilyMember,
FamilyMemberRole,
FamilyProfile,
MealPlan,
MealPlanItem,
MealPlanStatus,
MealType,
Recipe,
)
suffix = uuid.uuid4().hex[:8]
profile = FamilyProfile(
name=f"Test Family {suffix}",
household_size=2,
adult_count=2,
child_count=0,
)
db.add(profile)
db.flush()
voter_a = FamilyMember(
family_profile_id=profile.id,
name="Alice",
email=f"alice+{suffix}@example.com",
role=FamilyMemberRole.ADULT,
)
voter_b = FamilyMember(
family_profile_id=profile.id,
name="Bob",
email=f"bob+{suffix}@example.com",
role=FamilyMemberRole.ADULT,
)
db.add_all([voter_a, voter_b])
db.flush()
recipe = Recipe(
family_profile_id=profile.id,
name=f"Test Pasta {suffix}",
servings=2,
ingredients=[{"name": "pasta", "qty": "200g"}],
instructions=["Boil", "Drain"],
is_manually_added=True,
)
db.add(recipe)
db.flush()
plan = MealPlan(
family_profile_id=profile.id,
week_start_date=date.today() + timedelta(days=14),
status=MealPlanStatus.DRAFT,
)
db.add(plan)
db.flush()
item = MealPlanItem(
meal_plan_id=plan.id,
recipe_id=recipe.id,
day_of_week=1,
meal_type=MealType.DINNER,
)
db.add(item)
db.flush()
return {
"profile": profile,
"voter_a": voter_a,
"voter_b": voter_b,
"recipe": recipe,
"plan": plan,
"item": item,
}
# ---------------------------------------------------------------------------
# DB-backed tests.
# ---------------------------------------------------------------------------
@pytest.mark.requires_postgres
def test_consume_token_single_use(db, scenario):
from app.models import MealPlanVote
item = scenario["item"]
voter = scenario["voter_a"]
token = approval_service.issue_token(item.id, voter.id)
# First call: succeeds, returns the voter.
out = approval_service.consume_token(db, token, item.id)
assert out.id == voter.id
# Simulate the route writing the vote row (consume_token itself does NOT
# write — single-use is enforced by the presence of the vote row).
db.add(MealPlanVote(
meal_plan_item_id=item.id,
family_member_id=voter.id,
vote=True,
))
db.flush()
# Second call: raises 409.
with pytest.raises(HTTPException) as excinfo:
approval_service.consume_token(db, token, item.id)
assert excinfo.value.status_code == 409
@pytest.mark.requires_postgres
def test_vote_get_renders_html(client, db, scenario):
item = scenario["item"]
voter = scenario["voter_a"]
token = approval_service.issue_token(item.id, voter.id)
r = client.get(f"/api/meals/vote/{item.id}", params={"token": token})
assert r.status_code == 200, r.text
assert "text/html" in r.headers.get("content-type", "")
# Voter name appears in the visible body.
assert "Alice" in r.text
# Token is allowed inside the form `action` attribute (the link's href)
# but must not appear in the visible meal-card text.
body = r.text
card_start = body.find('<div class="meal">')
card_end = body.find("</div>", card_start)
assert card_start != -1 and card_end != -1
visible_card = body[card_start:card_end]
assert token not in visible_card
@pytest.mark.requires_postgres
def test_vote_post_records_and_decides(client, db, scenario):
"""First voter approves -> still pending (Bob hasn't voted).
Then Bob approves -> approved.
"""
item = scenario["item"]
voter_a = scenario["voter_a"]
voter_b = scenario["voter_b"]
token_a = approval_service.issue_token(item.id, voter_a.id)
r1 = client.post(
f"/api/meals/vote/{item.id}",
params={"token": token_a},
json={"vote": "approve"},
)
assert r1.status_code == 200, r1.text
body1 = r1.json()
assert body1["status"] == "recorded"
assert body1["item_status"] in ("pending", "approved")
# With 2 voters, after 1 approve we should still be pending.
assert body1["item_status"] == "pending"
token_b = approval_service.issue_token(item.id, voter_b.id)
r2 = client.post(
f"/api/meals/vote/{item.id}",
params={"token": token_b},
json={"vote": "approve"},
)
assert r2.status_code == 200, r2.text
body2 = r2.json()
assert body2["item_status"] == "approved"
+114
View File
@@ -0,0 +1,114 @@
"""
Auth gate tests for R1-B+D.
Covers:
- /api/admin/* requires bearer token (401 without, not 401 with valid).
- Mutating routes on family routers require a session cookie.
- /api/auth/login + /api/auth/logout round-trip with the shared password.
"""
from __future__ import annotations
import os
import pytest
# Configure auth secrets BEFORE app import. conftest.py runs first and sets
# DATABASE_URL; we layer auth env on top here.
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
os.environ.setdefault("SESSION_PASSWORD", "test-family-password")
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
@pytest.fixture(autouse=True)
def _reload_settings(monkeypatch):
"""Force ``settings`` to re-read env (test isolation)."""
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
monkeypatch.setenv("SESSION_PASSWORD", "test-family-password")
monkeypatch.setenv("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
# Re-instantiate the singleton so dependents pick up env.
from app import config as app_config
app_config.settings = app_config.Settings()
yield
# ---------------------------------------------------------------------------
# Admin bearer token
# ---------------------------------------------------------------------------
@pytest.mark.requires_postgres
def test_admin_requires_token(client):
"""No bearer → 401. Valid bearer → not 401 (handler runs)."""
r = client.post("/api/admin/scrape")
assert r.status_code == 401, r.text
r = client.post(
"/api/admin/scrape",
headers={"Authorization": "Bearer test-admin-token"},
)
# Handler may 200/202/500 (Playwright not installed in CI), but NOT 401.
assert r.status_code != 401, r.text
@pytest.mark.requires_postgres
def test_admin_logs_requires_token(client):
r = client.get("/api/admin/logs")
assert r.status_code == 401
r = client.get(
"/api/admin/logs", headers={"Authorization": "Bearer test-admin-token"}
)
assert r.status_code == 200
# ---------------------------------------------------------------------------
# Session-gated mutations
# ---------------------------------------------------------------------------
@pytest.mark.requires_postgres
def test_session_required_for_mutation(client):
"""POST /api/profile/members without cookie → 401."""
r = client.post(
"/api/profile/members",
json={"name": "x", "email": "x@y.z", "role": "voter"},
)
assert r.status_code == 401, r.text
@pytest.mark.requires_postgres
def test_session_open_for_reads(client):
"""GET /api/profile is NOT auth-gated (reads stay open)."""
r = client.get("/api/profile")
# Either 200 (profile exists) or 404 (no profile yet) — never 401.
assert r.status_code in (200, 404), r.text
# ---------------------------------------------------------------------------
# Login / logout
# ---------------------------------------------------------------------------
@pytest.mark.requires_postgres
def test_session_login_logout(client):
# Wrong password
r = client.post("/api/auth/login", json={"password": "nope"})
assert r.status_code == 401
# Right password sets the cookie
r = client.post(
"/api/auth/login", json={"password": "test-family-password"}
)
assert r.status_code == 204
assert "mp_session" in r.cookies, r.headers
# With cookie, mutation succeeds (or fails for non-auth reasons)
cookie_value = r.cookies.get("mp_session")
client.cookies.set("mp_session", cookie_value)
r2 = client.post(
"/api/profile/members",
json={"name": "x", "email": "test@example.com", "role": "voter"},
)
# 401 means the cookie was rejected — that's the bug we're guarding.
assert r2.status_code != 401, r2.text
# Logout clears the cookie
r3 = client.post("/api/auth/logout")
assert r3.status_code == 204
+19
View File
@@ -0,0 +1,19 @@
"""
Config fail-fast: importing app.config with DATABASE_URL unset must raise.
"""
from __future__ import annotations
import importlib
import pytest
def test_database_url_required(monkeypatch):
"""Settings() with empty DATABASE_URL → RuntimeError at instantiation."""
from app import config as app_config
monkeypatch.setenv("DATABASE_URL", "")
# Pass _env_file=None so a stray .env on disk can't satisfy the field.
with pytest.raises(RuntimeError, match="DATABASE_URL is required"):
app_config.Settings(_env_file=None, DATABASE_URL="")
+68
View File
@@ -0,0 +1,68 @@
"""Async scrape endpoint contract.
Verifies that ``POST /api/admin/scrape``:
- returns 202 + ``scrape_log_id`` synchronously,
- persists a ``ScrapeLog`` row in status STARTED before the background task
runs (the task is monkey-patched out so it never reaches Playwright and
never opens a session outside the test transaction).
"""
from __future__ import annotations
import os
import uuid
import pytest
os.environ.setdefault("ADMIN_TOKEN", "test-admin-token")
@pytest.fixture(autouse=True)
def _admin_token(monkeypatch):
monkeypatch.setenv("ADMIN_TOKEN", "test-admin-token")
yield
@pytest.mark.requires_postgres
def test_scrape_returns_202_and_log_id(client, db, monkeypatch):
"""Endpoint enqueues the scrape and returns 202 + scrape_log_id.
We replace the background runner with a no-op so the test does NOT spin up
Playwright and does NOT open a session outside the rolled-back test
transaction.
"""
calls: list[tuple] = []
def _fake_run(log_id, source, scrape_type):
calls.append((log_id, source, scrape_type))
# Patch in BOTH the service module (definition site) and the api module
# (import site) so whichever symbol the route resolved to is replaced.
monkeypatch.setattr(
"app.services.scraper_service._run_scrape_in_background", _fake_run
)
r = client.post(
"/api/admin/scrape",
headers={"Authorization": "Bearer test-admin-token"},
)
assert r.status_code == 202, r.text
body = r.json()
assert body["status"] == "queued"
assert "scrape_log_id" in body
log_id = uuid.UUID(body["scrape_log_id"])
# Row was committed inside enqueue_scrape — visible on the test session.
from app.models import ScrapeLog, ScrapeStatus
log = db.query(ScrapeLog).filter(ScrapeLog.id == log_id).first()
assert log is not None, "ScrapeLog row should exist after enqueue"
assert log.status == ScrapeStatus.STARTED
assert log.source == "lucky_california"
assert log.scrape_type == "weekly_ad"
assert log.completed_at is None
# TestClient runs background tasks before returning from the context
# manager exit — by the time we get here, the fake runner ran exactly once.
assert len(calls) == 1
assert calls[0][0] == log_id
+63
View File
@@ -0,0 +1,63 @@
"""
Smoke tests: app boots, routers wire up, no import-time crashes.
The router-list test asserts each endpoint returns 200 or 401 (auth gate not
yet implemented in R1-A's scope) but explicitly NOT 5xx — the goal is to catch
import errors and crashing handlers, not to validate business logic.
"""
from __future__ import annotations
import os
import pytest
def test_app_imports():
from app.main import app
assert app.title == "MealPlanner"
@pytest.mark.requires_postgres
def test_health(client):
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
@pytest.mark.requires_postgres
def test_health_db(client):
r = client.get("/health/db")
assert r.status_code == 200
body = r.json()
assert body.get("database") == "connected"
ROUTER_GET_PATHS = [
"/api/profile",
"/api/profile/members",
"/api/recipes",
"/api/recipes/ingredients",
"/api/meals",
"/api/pantry",
"/api/shopping-list",
"/api/admin/logs",
]
@pytest.mark.requires_postgres
@pytest.mark.parametrize("path", ROUTER_GET_PATHS)
def test_router_list_endpoints(client, path):
"""
Each canonical GET must respond. 200 (handled), 401 (admin-gated),
404 (handler ran but no row found) — all OK. 5xx means import/runtime
crash; 307 (trailing-slash redirect) means a handler is still mounted
on the wrong path.
"""
r = client.get(path, follow_redirects=False)
assert r.status_code < 500, (
f"{path} returned {r.status_code}: {r.text[:300]}"
)
# 307 is a regression — canonical paths must be the route definitions.
assert r.status_code in (200, 401, 404, 422), (
f"{path} returned unexpected {r.status_code}"
)
+223
View File
@@ -0,0 +1,223 @@
"""Offline tests for the Swiftly product-API client (R3-0).
All tests run against saved fixtures and mocked HTTP — no live network,
no Playwright/Chromium. Captured 2026-05-05 from a single live spike;
see ``.agent/context.md`` "Swiftly API" for the field-mapping rationale.
"""
from __future__ import annotations
import json
import sys
import uuid
from decimal import Decimal
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import requests
# Make `app.*` importable when pytest is invoked from the repo root.
BACKEND_DIR = Path(__file__).resolve().parent.parent
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from app.scraper.lucky_ca_scraper import ( # noqa: E402
LuckyCaliforniaScraper,
SwiftlyAuthError,
)
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "lucky_ca"
CATEGORIES_HTML = FIXTURE_DIR / "categories.html"
CATEGORY_JSON = FIXTURE_DIR / "category_meat_seafood.json"
pytestmark = pytest.mark.scraper_offline
# ---------------------------------------------------------------------------
# Pure-parser tests against captured fixtures
# ---------------------------------------------------------------------------
def test_parse_categories_fixture() -> None:
"""Parser returns >=10 distinct API slugs from the captured page."""
if not CATEGORIES_HTML.exists():
pytest.skip(f"fixture missing: {CATEGORIES_HTML}")
html = CATEGORIES_HTML.read_text(encoding="utf-8")
slugs = LuckyCaliforniaScraper.parse_categories_html(html)
assert len(slugs) >= 10, f"expected >=10 categories, got {len(slugs)}"
assert len(set(slugs)) == len(slugs), "slugs must be deduplicated"
# Every slug should look like `Product/<name>` per the API contract.
for s in slugs:
assert s.startswith("Product/"), f"unexpected slug shape: {s!r}"
# Spot-check the one we know is in the captured snapshot.
assert "Product/meat_seafood" in slugs
def test_parse_category_response_fixture() -> None:
"""Parser returns >=10 product dicts each with the mapped fields populated."""
if not CATEGORY_JSON.exists():
pytest.skip(f"fixture missing: {CATEGORY_JSON}")
payload = json.loads(CATEGORY_JSON.read_text(encoding="utf-8"))
raw_items = LuckyCaliforniaScraper.parse_category_response(payload)
assert len(raw_items) >= 10, f"expected >=10 raw items, got {len(raw_items)}"
mapped: list[dict] = []
for raw in raw_items:
m = LuckyCaliforniaScraper.map_product(
raw, aisle="meat_seafood", source_slug="Product/meat_seafood"
)
if m is not None:
mapped.append(m)
assert len(mapped) >= 10, (
f"expected >=10 mapped products, got {len(mapped)} "
f"(from {len(raw_items)} raw)"
)
sample = mapped[0]
# Required fields per the field-mapping contract.
for key in (
"external_id",
"source",
"name",
"current_price",
"regular_price",
"is_on_sale",
"image_url",
"aisle",
):
assert key in sample, f"missing key {key!r} in mapped product: {sample!r}"
assert sample["source"] == "lucky_california"
assert sample["aisle"] == "meat_seafood"
assert isinstance(sample["external_id"], str) and sample["external_id"]
assert isinstance(sample["name"], str) and sample["name"].strip()
assert isinstance(sample["regular_price"], Decimal)
assert sample["regular_price"] > 0
assert isinstance(sample["is_on_sale"], bool)
# Across the whole category at least SOME items should be on sale and
# at least some should have a regular-only price (sanity for the parser).
assert any(m["is_on_sale"] for m in mapped), "expected at least one sale item"
assert any(not m["is_on_sale"] for m in mapped), "expected at least one reg-only item"
def test_map_product_returns_none_for_unparseable() -> None:
"""Products with no name AND no parseable price are dropped."""
assert LuckyCaliforniaScraper.map_product({"name": ""}) is None
assert (
LuckyCaliforniaScraper.map_product(
{"id": "x", "name": "Foo", "price": {"ok": {}}}
)
is None
)
# ---------------------------------------------------------------------------
# 401 → SwiftlyAuthError → FAILED ScrapeLog
# ---------------------------------------------------------------------------
def _mock_response(status_code: int, payload=None) -> MagicMock:
resp = MagicMock(spec=requests.Response)
resp.status_code = status_code
if payload is not None:
resp.json.return_value = payload
if status_code >= 400:
resp.raise_for_status.side_effect = requests.HTTPError(
f"{status_code} error", response=resp
)
else:
resp.raise_for_status.return_value = None
return resp
def test_swiftly_auth_error_on_401_from_api() -> None:
"""A 401 from the API host raises SwiftlyAuthError before raise_for_status."""
scraper = LuckyCaliforniaScraper(bearer_token="stale-token")
with patch.object(scraper.api_session, "get", return_value=_mock_response(401)):
with pytest.raises(SwiftlyAuthError) as excinfo:
scraper.fetch_category("Product/meat_seafood")
assert "SWIFTLY_BEARER_TOKEN expired" in str(excinfo.value)
def test_swiftly_auth_error_when_token_missing() -> None:
"""An empty token short-circuits to SwiftlyAuthError without any HTTP call.
Force the token empty AFTER construction so the test is independent of
whatever ``SWIFTLY_BEARER_TOKEN`` happens to be set in the environment
(it WILL be set when pytest runs inside ``docker compose``).
"""
scraper = LuckyCaliforniaScraper()
scraper.bearer_token = ""
with patch.object(scraper.api_session, "get") as mock_get:
with pytest.raises(SwiftlyAuthError):
scraper.fetch_category("Product/meat_seafood")
mock_get.assert_not_called()
@pytest.mark.requires_postgres
def test_background_runner_writes_failed_with_token_message(monkeypatch):
"""A 401 during the background scrape lands in ScrapeLog as FAILED + message.
Uses a real (non-fixture) session so the bg runner's rollback+re-query
path mirrors production. The bg runner commits the FAILED row; we clean
up explicitly at the end.
"""
from app.models import ScrapeLog, ScrapeStatus
from app.services import scraper_service
from app.scraper.lucky_ca_scraper import SwiftlyAuthError, LuckyCaliforniaScraper
from app.database import SessionLocal
from datetime import datetime, timezone
log_id = uuid.uuid4()
setup_session = SessionLocal()
try:
setup_session.add(
ScrapeLog(
id=log_id,
source="lucky_california",
scrape_type="weekly_ad",
status=ScrapeStatus.STARTED,
started_at=datetime.now(timezone.utc),
)
)
setup_session.commit()
finally:
setup_session.close()
def _explode(self):
raise SwiftlyAuthError(
"SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user "
"(capture from luckysupermarkets.com network tab on a /search/api/v1 request)"
)
monkeypatch.setattr(LuckyCaliforniaScraper, "fetch_all", _explode)
try:
scraper_service._run_scrape_in_background(
log_id, "lucky_california", "weekly_ad"
)
verify_session = SessionLocal()
try:
refreshed = (
verify_session.query(ScrapeLog)
.filter(ScrapeLog.id == log_id)
.first()
)
assert refreshed is not None
assert refreshed.status == ScrapeStatus.FAILED
assert "SWIFTLY_BEARER_TOKEN expired" in (refreshed.error_message or "")
assert refreshed.completed_at is not None
finally:
verify_session.close()
finally:
cleanup_session = SessionLocal()
try:
cleanup_session.query(ScrapeLog).filter(ScrapeLog.id == log_id).delete()
cleanup_session.commit()
finally:
cleanup_session.close()