From 155f9bd1471193b27182ac81d15c27da77d54907 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 22:05:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/api/products/export=20?= =?UTF-8?q?=E2=80=94=20streamed=20canonical=20CSV,=20409=20empty=5Fcatalog?= =?UTF-8?q?=20(=C2=A76.4,=20PUC-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/main.py | 23 +++++++++++++- backend/tests/test_products_endpoints.py | 40 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/backend/app/main.py b/backend/app/main.py index 2c350fc..71ddd98 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,7 +17,7 @@ from typing import Any import psycopg from fastapi import Depends, FastAPI, File, Query, Response, UploadFile -from fastapi.responses import JSONResponse, PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -374,6 +374,27 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = _account, sf = gate return products.summary(conn, sf.id) + @app.get("/api/products/export") + def export_products( + status: str = Query(default="all", pattern="^(all|active|draft|archived)$"), + conn: psycopg.Connection = Depends(get_conn), + sess: dict | None = Depends(get_session), + ): + """Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9).""" + gate = _merchant_gate(conn, sess) + if isinstance(gate, JSONResponse): + return gate + _account, sf = gate + try: + stream = products.export_catalog(conn, sf.id, status) + except products.EmptyCatalog: + return _error(409, "empty_catalog", "There are no products to export.") + return StreamingResponse( + stream, + media_type="text/csv", + headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'}, + ) + @app.get("/api/products/sample.csv") def products_sample_csv(): """The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4).""" diff --git a/backend/tests/test_products_endpoints.py b/backend/tests/test_products_endpoints.py index 2f06802..dbab21a 100644 --- a/backend/tests/test_products_endpoints.py +++ b/backend/tests/test_products_endpoints.py @@ -93,3 +93,43 @@ def test_sample_csv_imports_clean(fresh_db_url): 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 + + +def test_export_returns_canonical_csv(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + draft = _upload(client).json() + client.post(f"/api/products/imports/drafts/{draft['id']}/confirm") + resp = client.get("/api/products/export") + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/csv") + assert "attachment" in resp.headers["content-disposition"] + body = resp.text + assert body.splitlines()[0].startswith("Handle,") + assert "moon-mug" in body + + +def test_export_status_filter_respected(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + # GOOD_CSV's moon-mug has no Status column → defaults to active. + draft = _upload(client).json() + client.post(f"/api/products/imports/drafts/{draft['id']}/confirm") + assert "moon-mug" in client.get("/api/products/export?status=active").text + # No archived products → 409. + assert client.get("/api/products/export?status=archived").status_code == 409 + + +def test_export_empty_catalog_409(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + resp = client.get("/api/products/export") + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == "empty_catalog" + + +def test_export_requires_merchant(fresh_db_url): + with TestClient(create_app(database_url=fresh_db_url)) as client: + assert client.get("/api/products/export").status_code == 401 + + +def test_export_bad_status_422(fresh_db_url): + with _merchant_client(fresh_db_url) as client: + assert client.get("/api/products/export?status=bogus").status_code == 422