feat(products): streamed export service + TEL-3 + EmptyCatalog (PUC-9, §9.1)

This commit is contained in:
2026-06-11 22:03:09 -07:00
parent 5a97b9dc59
commit 1c2a6af986
4 changed files with 91 additions and 5 deletions
+4 -2
View File
@@ -11,6 +11,7 @@ from pathlib import Path
from .errors import ( from .errors import (
DraftExpired, DraftExpired,
DraftNotFound, DraftNotFound,
EmptyCatalog,
FileRejected, FileRejected,
NothingToApply, NothingToApply,
PreviewStale, PreviewStale,
@@ -21,6 +22,7 @@ from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
from .service import ( from .service import (
confirm_draft, confirm_draft,
discard_draft, discard_draft,
export_catalog,
get_draft, get_draft,
get_draft_records, get_draft_records,
get_run, get_run,
@@ -34,8 +36,8 @@ SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
__all__ = [ __all__ = [
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound", "PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog",
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", "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", "export_catalog",
] ]
+4
View File
@@ -35,3 +35,7 @@ class NothingToApply(ProductsError):
class RunNotFound(ProductsError): class RunNotFound(ProductsError):
"""No such import run for this storefront.""" """No such import run for this storefront."""
class EmptyCatalog(ProductsError):
"""PUC-9: nothing to export (no products, or none matching the status filter)."""
+38 -2
View File
@@ -7,14 +7,22 @@ writes exactly one row — the import_draft. TEL events per §9.1.
from __future__ import annotations from __future__ import annotations
import time import time
from collections.abc import Iterator
from datetime import datetime, timezone from datetime import datetime, timezone
import psycopg import psycopg
from app.platform import telemetry from app.platform import telemetry
from . import codec, diff, repo, validate from . import codec, diff, repo, serialize, validate
from .errors import DraftExpired, DraftNotFound, NothingToApply, PreviewStale, RunNotFound from .errors import (
DraftExpired,
DraftNotFound,
EmptyCatalog,
NothingToApply,
PreviewStale,
RunNotFound,
)
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int, def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
@@ -218,6 +226,34 @@ def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
return run return run
def export_catalog(
conn: psycopg.Connection, storefront_id: int, status_filter: str
) -> Iterator[str]:
"""Stream the storefront's catalog as canonical CSV (PUC-9; INV-12 codec).
Read-only: builds the snapshot, then streams the serializer over it. TEL-3
is emitted once the stream is exhausted, with the product count and elapsed
time. Raises EmptyCatalog before yielding anything if the (filtered) catalog
is empty, so the BFF can answer 409 cleanly with no partial body.
"""
started = time.monotonic()
snapshot = repo.export_catalog(conn, storefront_id, status_filter)
if not snapshot:
raise EmptyCatalog()
def _stream() -> Iterator[str]:
yield from serialize.catalog_to_csv(snapshot)
telemetry.emit(
"catalog_exported",
storefront_id=storefront_id,
status_filter=status_filter,
product_count=len(snapshot),
duration_ms=int((time.monotonic() - started) * 1000),
)
return _stream()
def summary(conn: psycopg.Connection, storefront_id: int) -> dict: def summary(conn: psycopg.Connection, storefront_id: int) -> dict:
"""The products dashboard counts (§6.4).""" """The products dashboard counts (§6.4)."""
return { return {
+45 -1
View File
@@ -8,7 +8,7 @@ import psycopg
import pytest import pytest
from app.domains import products from app.domains import products
from app.domains.products import repo, serialize from app.domains.products import repo
from app.platform import db from app.platform import db
CSV = ( CSV = (
@@ -54,3 +54,47 @@ def test_export_status_filter(migrated_conn, merchant):
assert [p.handle for p in active] == ["active-mug"] assert [p.handle for p in active] == ["active-mug"]
draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft") draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft")
assert [p.handle for p in draft] == ["draft-tee"] assert [p.handle for p in draft] == ["draft-tee"]
@pytest.fixture()
def telemetry_propagation():
"""create_app() sets propagate=False on the parent "ecomm" logger
(main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
root-logger handler whenever an API test ran first. Restore propagation here."""
lg = logging.getLogger("ecomm")
prior = lg.propagate
lg.propagate = True
yield
lg.propagate = prior
def test_export_streams_canonical_csv(migrated_conn, merchant):
_seed(migrated_conn, merchant)
text = "".join(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
rows = list(csv.DictReader(io.StringIO(text)))
assert {r["Handle"] for r in rows} == {"active-mug", "draft-tee"}
assert rows[0]["Handle"] == "active-mug" # sorted by handle
def test_export_empty_raises(migrated_conn, merchant):
# No catalog at all → empty.
with pytest.raises(products.EmptyCatalog):
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
def test_export_empty_after_filter_raises(migrated_conn, merchant):
_seed(migrated_conn, merchant) # only active + draft exist
with pytest.raises(products.EmptyCatalog):
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "archived"))
def test_tel3_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
_seed(migrated_conn, merchant)
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
exported = [e for e in events if e["event"] == "catalog_exported"]
assert len(exported) == 1
assert exported[0]["product_count"] == 2
assert exported[0]["status_filter"] == "all"
assert "duration_ms" in exported[0]