Public Access
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:
@@ -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
@@ -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.
|
||||
+80
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+89
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 582 KiB |
@@ -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}"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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="")
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user