feat(products): /api/products/* BFF endpoints + sample.csv (DOC-3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from .errors import (
|
from .errors import (
|
||||||
DraftExpired,
|
DraftExpired,
|
||||||
DraftNotFound,
|
DraftNotFound,
|
||||||
@@ -27,10 +29,13 @@ from .service import (
|
|||||||
summary,
|
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__ = [
|
__all__ = [
|
||||||
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
||||||
"PreviewStale", "NothingToApply", "RunNotFound",
|
"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",
|
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
||||||
"confirm_draft", "list_runs", "get_run", "summary",
|
"confirm_draft", "list_runs", "get_run", "summary",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
+179
-4
@@ -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
|
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 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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -15,12 +16,12 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
from fastapi import Depends, FastAPI, Response
|
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
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 config, db
|
||||||
from app.platform import mailer as mailer_mod
|
from app.platform import mailer as mailer_mod
|
||||||
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
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
|
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:
|
def _ensure_app_logging() -> None:
|
||||||
"""Surface the app's own `ecomm.*` INFO logs on stderr (idempotent).
|
"""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})
|
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
|
# 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
|
# 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.
|
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user