From 4dacc2dafdbb8411f995d2a7df7bb4b4e83aa590 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 16:18:34 -0700 Subject: [PATCH] feat(products): /api/products/* BFF endpoints + sample.csv (DOC-3) Co-Authored-By: Claude Fable 5 --- backend/app/domains/products/__init__.py | 7 +- backend/app/domains/products/sample.csv | 6 + backend/app/main.py | 183 ++++++++++++++++++++++- backend/tests/test_products_endpoints.py | 95 ++++++++++++ 4 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 backend/app/domains/products/sample.csv create mode 100644 backend/tests/test_products_endpoints.py diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py index 85f82d8..c2608ff 100644 --- a/backend/app/domains/products/__init__.py +++ b/backend/app/domains/products/__init__.py @@ -6,6 +6,8 @@ drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation """ from __future__ import annotations +from pathlib import Path + from .errors import ( DraftExpired, DraftNotFound, @@ -27,10 +29,13 @@ from .service import ( summary, ) +# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv. +SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv" + __all__ = [ "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "PreviewStale", "NothingToApply", "RunNotFound", - "MAX_DATA_ROWS", "MAX_FILE_BYTES", + "MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", "import_validate", "get_draft", "get_draft_records", "discard_draft", "confirm_draft", "list_runs", "get_run", "summary", ] diff --git a/backend/app/domains/products/sample.csv b/backend/app/domains/products/sample.csv new file mode 100644 index 0000000..3a55af1 --- /dev/null +++ b/backend/app/domains/products/sample.csv @@ -0,0 +1,6 @@ +Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text +moon-mug,Moon Mug,"

A ceramic mug glazed in moonlight grey.

",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk +star-tee,Star Tee,"

Soft cotton tee with a hand-printed star.

",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay +star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,, +star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,, +star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print diff --git a/backend/app/main.py b/backend/app/main.py index c2c0c69..2c350fc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,8 @@ SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4 translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the -_storefront_for seam from the storefronts domain. +_storefront_for seam from the storefronts domain. SLICE-5 adds the /api/products/* import +spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping. """ from __future__ import annotations @@ -15,12 +16,12 @@ from pathlib import Path from typing import Any import psycopg -from fastapi import Depends, FastAPI, Response -from fastapi.responses import JSONResponse +from fastapi import Depends, FastAPI, File, Query, Response, UploadFile +from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel -from app.domains import accounts, storefronts +from app.domains import accounts, products, storefronts from app.platform import config, db from app.platform import mailer as mailer_mod from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session @@ -61,6 +62,26 @@ def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict return {"id": sf.id, "name": sf.name} if sf else None +def _merchant_gate( + conn: psycopg.Connection, sess: dict | None +) -> JSONResponse | tuple[accounts.Account, storefronts.Storefront]: + """The shared /api/products/* gate: a signed-in account that has its storefront. + + Returns the (account, storefront) pair, or the ready-to-return error response — + 401 with no session, 404 before the storefront exists (INV-14: every products + call is storefront-scoped, so there is nothing to address yet). + """ + if sess is None: + return _error(401, "unauthenticated", "You are not signed in.") + account = accounts.get_account(conn, sess["account_id"]) + if account is None: + return _error(401, "unauthenticated", "You are not signed in.") + sf = storefronts.storefront_for(conn, account.id) + if sf is None: + return _error(404, "no_storefront", "Create your storefront first.") + return account, sf + + def _ensure_app_logging() -> None: """Surface the app's own `ecomm.*` INFO logs on stderr (idempotent). @@ -208,6 +229,160 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = ) return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name}) + @app.post("/api/products/imports") + async def import_upload( + file: UploadFile = File(...), + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """Upload a CSV → validated import draft (§6.4; PUC-2, PUC-5/5a on rejection).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + account, sf = gate + data = await file.read() + if len(data) > products.MAX_FILE_BYTES: + return _error(413, "file_too_large", "This file is larger than 10 MB.") + try: + draft = products.import_validate(conn, sf.id, account.id, file.filename or "upload.csv", data) + except products.FileRejected as exc: + return _error(400, exc.code, exc.message) + return JSONResponse(status_code=201, content=draft) + + @app.get("/api/products/imports/drafts/{draft_id}") + def get_import_draft( + draft_id: int, + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """One draft's preview payload — summary, never the file bytes (§6.4; PUC-3).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + try: + return products.get_draft(conn, sf.id, draft_id) + except products.DraftNotFound: + return _error(404, "not_found", "No such import preview.") + except products.DraftExpired: + return _error(410, "draft_expired", "This preview expired — upload the file again.") + + @app.get("/api/products/imports/drafts/{draft_id}/records") + def get_import_draft_records( + draft_id: int, + kind: str | None = Query(default=None, pattern="^(add|update|unchanged|error)$"), + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """The draft's per-product preview records, paged + kind-filtered (§6.4; PUC-3).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + try: + records = products.get_draft_records(conn, sf.id, draft_id, kind, limit, offset) + except products.DraftNotFound: + return _error(404, "not_found", "No such import preview.") + except products.DraftExpired: + return _error(410, "draft_expired", "This preview expired — upload the file again.") + return {"records": records} + + @app.post("/api/products/imports/drafts/{draft_id}/confirm") + def confirm_import_draft( + draft_id: int, + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """Apply the previewed diff as one import run (§6.4; PUC-4, INV-10/11).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + account, sf = gate + try: + run_id = products.confirm_draft(conn, sf.id, account.id, draft_id) + except products.DraftNotFound: + return _error(404, "not_found", "No such import preview.") + except products.DraftExpired: + return _error(410, "draft_expired", "This preview expired — upload the file again.") + except products.PreviewStale: + return _error( + 409, "preview_stale", + "Your catalog changed since this preview — upload the file again.", + ) + except products.NothingToApply: + return _error( + 409, "nothing_to_apply", + "Nothing to change — your catalog already matches this file.", + ) + return JSONResponse(status_code=201, content={"run_id": run_id}) + + @app.delete("/api/products/imports/drafts/{draft_id}") + def discard_import_draft( + draft_id: int, + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """Discard the draft, no trace kept; idempotent (§6.4; PUC-3a).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + products.discard_draft(conn, sf.id, draft_id) + return Response(status_code=204) + + @app.get("/api/products/imports/runs") + def list_import_runs( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """The storefront's import history, newest first (§6.4; PUC-8).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + return {"runs": products.list_runs(conn, sf.id, limit, offset)} + + @app.get("/api/products/imports/runs/{run_id}") + def get_import_run( + run_id: int, + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """One run's detail payload, errors included (§6.4; PUC-8).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + try: + return products.get_run(conn, sf.id, run_id) + except products.RunNotFound: + return _error(404, "not_found", "No such import run.") + + @app.get("/api/products/summary") + def products_summary( + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """The products dashboard counts (§6.4).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + return products.summary(conn, sf.id) + + @app.get("/api/products/sample.csv") + def products_sample_csv(): + """The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4).""" + return PlainTextResponse( + products.SAMPLE_CSV_PATH.read_text(), + media_type="text/csv", + headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'}, + ) + # Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the # backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the # dist dir doesn't exist (Vite serves the frontend) and the mount is skipped. diff --git a/backend/tests/test_products_endpoints.py b/backend/tests/test_products_endpoints.py new file mode 100644 index 0000000..2f06802 --- /dev/null +++ b/backend/tests/test_products_endpoints.py @@ -0,0 +1,95 @@ +"""§6.4 /api/products/* endpoint scenarios (PUC-2/3/3a/4/5/5a/8 + gates).""" +import io +import re +from contextlib import contextmanager + +from fastapi.testclient import TestClient + +from app.main import create_app + +GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\n" + + +@contextmanager +def _merchant_client(fresh_db_url, email="m@example.com"): + with TestClient(create_app(database_url=fresh_db_url)) as client: + client.post("/api/auth/request-code", json={"email": email}) + code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) + client.post("/api/auth/verify", json={"email": email, "code": code}) + client.post("/api/storefronts", json={}) + yield client + + +def _upload(client, data=GOOD_CSV, name="cat.csv"): + return client.post("/api/products/imports", files={"file": (name, io.BytesIO(data), "text/csv")}) + + +def test_upload_returns_201_draft(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + resp = _upload(client) + assert resp.status_code == 201 + body = resp.json() + assert body["summary"]["adds"] == 1 and body["dialect"] == "canonical" + + +def test_upload_rejections_carry_codes(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + resp = _upload(client, b"Vendor\nAcme\n") + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "missing_required_column" + resp = _upload(client, b"Handle,Title\n" + b"x" * (10 * 1024 * 1024 + 1)) + assert resp.status_code == 413 + + +def test_unauthenticated_401_and_no_storefront_404(fresh_db_url): + with TestClient(create_app(database_url=fresh_db_url)) as client: + assert _upload(client).status_code == 401 + client.post("/api/auth/request-code", json={"email": "x@example.com"}) + code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) + client.post("/api/auth/verify", json={"email": "x@example.com", "code": code}) + assert _upload(client).status_code == 404 + + +def test_preview_confirm_run_flow(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + draft = _upload(client).json() + recs = client.get(f"/api/products/imports/drafts/{draft['id']}/records").json()["records"] + assert recs[0]["kind"] == "add" + run_id = client.post(f"/api/products/imports/drafts/{draft['id']}/confirm").json()["run_id"] + run = client.get(f"/api/products/imports/runs/{run_id}").json() + assert run["products_added"] == 1 and run["by"] == "m@example.com" + assert client.get("/api/products/summary").json()["product_count"] == 1 + assert client.get("/api/products/imports/runs").json()["runs"][0]["id"] == run_id + + +def test_cancel_no_trace_puc3a(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + draft = _upload(client).json() + assert client.delete(f"/api/products/imports/drafts/{draft['id']}").status_code == 204 + assert client.get(f"/api/products/imports/drafts/{draft['id']}").status_code == 404 + assert client.get("/api/products/imports/runs").json()["runs"] == [] + + +def test_confirm_conflicts(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + d1 = _upload(client).json() + client.post(f"/api/products/imports/drafts/{d1['id']}/confirm") + d2 = _upload(client).json() + resp = client.post(f"/api/products/imports/drafts/{d2['id']}/confirm") + assert resp.status_code == 409 and resp.json()["error"]["code"] == "nothing_to_apply" + + +def test_sample_csv_served(fresh_db_url): + with TestClient(create_app(database_url=fresh_db_url)) as client: + resp = client.get("/api/products/sample.csv") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/csv") + assert resp.text.startswith("Handle,Title,") + + +def test_sample_csv_imports_clean(fresh_db_url): + """DOC-3 honesty: our own sample must validate with zero errors.""" + with _merchant_client(fresh_db_url) as client: + sample = client.get("/api/products/sample.csv").content + body = _upload(client, sample, "sample.csv").json() + assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2