feat(products): /api/products/* BFF endpoints + sample.csv (DOC-3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:18:34 -07:00
parent a8538d3ecc
commit 4dacc2dafd
4 changed files with 286 additions and 5 deletions
+6 -1
View File
@@ -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",
]
+6
View File
@@ -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,"<p>A ceramic mug glazed in moonlight grey.</p>",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,"<p>Soft cotton tee with a hand-printed star.</p>",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
1 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
2 moon-mug Moon Mug <p>A ceramic mug glazed in moonlight grey.</p> 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
3 star-tee Star Tee <p>Soft cotton tee with a hand-printed star.</p> 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
4 star-tee M Indigo WG-TEE-M 24.00 18
5 star-tee L Indigo WG-TEE-L 26.00 9
6 star-tee https://images.example.com/star-tee-back.jpg 2 Star Tee back print
+179 -4
View File
@@ -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.
+95
View File
@@ -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