Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 385df8d728 | |||
| af72299a57 | |||
| 6f22c8b146 | |||
| 767011fbae | |||
| 8077f2e07b | |||
| 0a85c4fef8 | |||
| 6f213e1f02 | |||
| 155f9bd147 | |||
| 1c2a6af986 | |||
| 5a97b9dc59 | |||
| fce0b5eaed | |||
| b40e4d30b5 | |||
| 7652e92cbe | |||
| 627e257d4c | |||
| 011f4d5dc1 | |||
| 2606fbf826 | |||
| ad738adbc0 | |||
| a58f42cf86 | |||
| d18a84e135 | |||
| 87a56a66c4 | |||
| 4141285b89 | |||
| 188494272a | |||
| 26f84cb916 | |||
| b5dac8886f | |||
| 4dacc2dafd | |||
| a8538d3ecc | |||
| 0c7865e9e1 | |||
| fcbf1393f5 | |||
| 138126ab17 | |||
| 667a462e0b | |||
| 9bc6e4dbd2 | |||
| 19ee695c20 | |||
| f64c3fddf9 | |||
| c39bbd4728 | |||
| 0ee948b34d | |||
| 1267d4f29d | |||
| 6b405a0f2d |
@@ -0,0 +1,43 @@
|
||||
"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
|
||||
|
||||
Owns the canonical row model, codec, validation, diff engine, and import
|
||||
drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
|
||||
(INV-10). Imported via this package surface only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import (
|
||||
DraftExpired,
|
||||
DraftNotFound,
|
||||
EmptyCatalog,
|
||||
FileRejected,
|
||||
NothingToApply,
|
||||
PreviewStale,
|
||||
ProductsError,
|
||||
RunNotFound,
|
||||
)
|
||||
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||
from .service import (
|
||||
confirm_draft,
|
||||
discard_draft,
|
||||
export_catalog,
|
||||
get_draft,
|
||||
get_draft_records,
|
||||
get_run,
|
||||
import_validate,
|
||||
list_runs,
|
||||
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", "EmptyCatalog",
|
||||
"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", "export_catalog",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""CSV codec — bytes → ParsedFile (SD-0002 §6.5.1). File-level gates only (PUC-5a);
|
||||
row semantics live in validate.py. Dialect detection is the INV-17 seam (SLICE-8
|
||||
adds Shopify)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
from .errors import FileRejected
|
||||
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||
|
||||
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
||||
|
||||
|
||||
def detect_dialect(header: list[str]) -> str:
|
||||
"""The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here."""
|
||||
return "canonical"
|
||||
|
||||
|
||||
def parse_csv(data: bytes) -> ParsedFile:
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
|
||||
try:
|
||||
text = data.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
reader = csv.reader(io.StringIO(text))
|
||||
try:
|
||||
try:
|
||||
raw_header = next(reader)
|
||||
except StopIteration:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
header = [h.strip() for h in raw_header]
|
||||
for col in _REQUIRED_HEADER_COLUMNS:
|
||||
if col not in header:
|
||||
raise FileRejected(
|
||||
"missing_required_column",
|
||||
f"This file is missing the required column '{col}'.",
|
||||
)
|
||||
# First occurrence of a duplicated column wins.
|
||||
col_index: dict[str, int] = {}
|
||||
for i, name in enumerate(header):
|
||||
if name and name not in col_index:
|
||||
col_index[name] = i
|
||||
known_present = [c for c in col_index if c in KNOWN_COLUMNS]
|
||||
unknown = [c for c in col_index if c not in KNOWN_COLUMNS]
|
||||
rows: list[Row] = []
|
||||
for raw in reader:
|
||||
if not any(cell.strip() for cell in raw):
|
||||
continue
|
||||
if len(rows) >= MAX_DATA_ROWS:
|
||||
raise FileRejected(
|
||||
"too_many_rows",
|
||||
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
|
||||
)
|
||||
cells = {
|
||||
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||
for c in known_present
|
||||
}
|
||||
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||
except csv.Error:
|
||||
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||
return ParsedFile(
|
||||
dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows
|
||||
)
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Diff engine — catalog × canonical products → apply plan + preview records (SD-0002 §6.5.2).
|
||||
|
||||
Classifies each canonical product against the storefront's current catalog as
|
||||
add / update / unchanged / error. One walk produces two views of the same
|
||||
computation: a typed apply *plan* carrying resolved native values (Decimal etc.)
|
||||
for the confirm transaction, and JSON-ready preview records derived from that
|
||||
walk (stored as draft JSONB, served verbatim to the SPA) — so what confirm
|
||||
applies is exactly what preview showed (INV-11). A summary and a deterministic
|
||||
fingerprint over the records detect catalog drift between preview and confirm.
|
||||
|
||||
Deliberately DB-free: the catalog snapshot dataclasses are defined here and the
|
||||
repo layer builds them. Only fields present in the file's canonical fields{}
|
||||
participate in a comparison — an absent column is untouched, never a change
|
||||
(§6.5.1); a present-but-empty cell resolves to the field's CLEAR_DEFAULTS entry
|
||||
— except a variant's position, whose default is the variant's 1-based file
|
||||
order within its product ("defaults to file order"), resolved here at diff time.
|
||||
Catalog variants/images absent from the file are likewise untouched (INV-10);
|
||||
file variants match catalog variants by their option-value combination (INV-13).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogVariant:
|
||||
id: int
|
||||
options: tuple[str | None, str | None, str | None]
|
||||
position: int
|
||||
# sku, barcode, price (Decimal|None), cost, weight, weight_unit, volume,
|
||||
# volume_unit, tax_id_1, tax_id_2, inventory_tracker, inventory_qty,
|
||||
# variant_image (linked image source_url or None)
|
||||
fields: dict[str, object]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogImage:
|
||||
id: int
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogProduct:
|
||||
id: int
|
||||
handle: str
|
||||
title: str
|
||||
option_names: tuple[str | None, str | None, str | None]
|
||||
# title, description_html, vendor, product_type, google_product_category,
|
||||
# tags (list[str]), status, published (bool)
|
||||
fields: dict[str, object]
|
||||
variants: list[CatalogVariant]
|
||||
images: list[CatalogImage]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply plan — the typed twin of the preview records. confirm_draft executes
|
||||
# these; the values are resolved natives (Decimal, bool, list), never the
|
||||
# json-safe strings the records carry for display.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VariantPlan:
|
||||
kind: str # "add" | "update"
|
||||
canonical: CanonicalVariant
|
||||
catalog_id: int | None # None for add
|
||||
# 1-based index within the product's file variants — the position default.
|
||||
file_order: int = 0
|
||||
# field -> resolved after-value (update only); adds resolve from canonical.fields
|
||||
changes: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImagePlan:
|
||||
kind: str # "add" | "update"
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
image_id: int | None # None for add
|
||||
changes: dict[str, object] = field(default_factory=dict) # subset of {"position","alt_text"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductPlan:
|
||||
kind: str # "add" | "update" | "unchanged" | "error"
|
||||
canonical: CanonicalProduct
|
||||
catalog: CatalogProduct | None
|
||||
# field -> resolved after-value (update only; may include title/option*_name)
|
||||
product_changes: dict[str, object] = field(default_factory=dict)
|
||||
variant_plans: list[VariantPlan] = field(default_factory=list)
|
||||
image_plans: list[ImagePlan] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiffResult:
|
||||
records: list[dict]
|
||||
summary: dict
|
||||
fingerprint: str
|
||||
plan: list[ProductPlan]
|
||||
|
||||
|
||||
# Option-name presence markers in fields{} (see validate.py); the values are
|
||||
# compared via the option_names attribute, not through the generic field loop.
|
||||
_OPTION_NAME_FIELDS = ("option1_name", "option2_name", "option3_name")
|
||||
_SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "error": "errors"}
|
||||
|
||||
|
||||
def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult:
|
||||
records: list[dict] = []
|
||||
plan: list[ProductPlan] = []
|
||||
summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
for product in products:
|
||||
current = catalog.get(product.handle)
|
||||
if product.errors:
|
||||
product_plan = ProductPlan(kind="error", canonical=product, catalog=current)
|
||||
detail: dict = {"errors": [e.as_json() for e in product.errors]}
|
||||
elif current is None:
|
||||
product_plan = _add_plan(product)
|
||||
detail = _add_detail(product_plan)
|
||||
else:
|
||||
product_plan, detail = _update_plan(product, current)
|
||||
kind = product_plan.kind
|
||||
plan.append(product_plan)
|
||||
records.append(
|
||||
{
|
||||
"handle": product.handle,
|
||||
"title": product.title or (current.title if current else ""),
|
||||
"kind": kind,
|
||||
"variant_count": len(current.variants) if kind == "unchanged" else len(product.variants),
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
summary[_SUMMARY_KEY[kind]] += 1
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
|
||||
|
||||
|
||||
def _add_plan(product: CanonicalProduct) -> ProductPlan:
|
||||
return ProductPlan(
|
||||
kind="add",
|
||||
canonical=product,
|
||||
catalog=None,
|
||||
variant_plans=[
|
||||
VariantPlan(kind="add", canonical=v, catalog_id=None, file_order=order)
|
||||
for order, v in enumerate(product.variants, start=1)
|
||||
],
|
||||
image_plans=[
|
||||
ImagePlan(kind="add", source_url=i.source_url, position=i.position,
|
||||
alt_text=i.alt_text, image_id=None)
|
||||
for i in product.images
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _add_detail(plan: ProductPlan) -> dict:
|
||||
# On add, absent fields fall back to their defaults for display where one
|
||||
# exists — the detail shows what will actually be set.
|
||||
set_fields = resolved_product_fields(plan.canonical)
|
||||
for field_name, default in CLEAR_DEFAULTS.items():
|
||||
set_fields.setdefault(field_name, default)
|
||||
return {
|
||||
"set": {f: _json_safe(v) for f, v in set_fields.items()},
|
||||
"option_names": list(plan.canonical.option_names),
|
||||
"variants": [
|
||||
{"options": list(vp.canonical.options),
|
||||
"set": _resolved_variant_fields(vp.canonical, vp.file_order)}
|
||||
for vp in plan.variant_plans
|
||||
],
|
||||
"images": [
|
||||
{"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
|
||||
for ip in plan.image_plans
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]:
|
||||
"""One walk, two outputs: the resolved-value plan entries and the json-safe
|
||||
record detail entries are appended side by side, so they can never diverge."""
|
||||
product_changes: dict[str, object] = {}
|
||||
changes: list[dict] = []
|
||||
# Title is always file-present (required header column); "" means the block
|
||||
# already carries an error and never reaches here.
|
||||
if product.title and product.title != current.title:
|
||||
product_changes["title"] = product.title
|
||||
changes.append(_change("title", current.title, product.title))
|
||||
for field_name, resolved in resolved_product_fields(product).items():
|
||||
before = current.fields.get(field_name)
|
||||
if resolved != before:
|
||||
product_changes[field_name] = resolved
|
||||
changes.append(_change(field_name, before, resolved))
|
||||
for slot in (1, 2, 3):
|
||||
if f"option{slot}_name" not in product.fields:
|
||||
continue
|
||||
before, after = current.option_names[slot - 1], product.option_names[slot - 1]
|
||||
if after != before:
|
||||
product_changes[f"option{slot}_name"] = after
|
||||
changes.append(_change(f"option{slot}_name", before, after))
|
||||
|
||||
variant_plans: list[VariantPlan] = []
|
||||
variant_entries: list[dict] = []
|
||||
by_options = {v.options: v for v in current.variants}
|
||||
for file_order, variant in enumerate(product.variants, start=1):
|
||||
match = by_options.get(variant.options)
|
||||
if match is None:
|
||||
variant_plans.append(
|
||||
VariantPlan(kind="add", canonical=variant, catalog_id=None, file_order=file_order)
|
||||
)
|
||||
variant_entries.append(
|
||||
{"options": list(variant.options), "kind": "add",
|
||||
"set": _resolved_variant_fields(variant, file_order)}
|
||||
)
|
||||
continue
|
||||
variant_changes: dict[str, object] = {}
|
||||
variant_change_entries: list[dict] = []
|
||||
for f, resolved in resolved_variant_fields(variant, file_order).items():
|
||||
# position lives on the catalog variant as an attribute, not in
|
||||
# fields{} — compare it explicitly (as images do for theirs).
|
||||
before = match.position if f == "position" else match.fields.get(f)
|
||||
if resolved != before:
|
||||
variant_changes[f] = resolved
|
||||
variant_change_entries.append(_change(f, before, resolved))
|
||||
if variant_changes:
|
||||
variant_plans.append(
|
||||
VariantPlan(kind="update", canonical=variant, catalog_id=match.id,
|
||||
file_order=file_order, changes=variant_changes)
|
||||
)
|
||||
variant_entries.append(
|
||||
{"options": list(variant.options), "kind": "update", "changes": variant_change_entries}
|
||||
)
|
||||
|
||||
image_plans: list[ImagePlan] = []
|
||||
image_entries: list[dict] = []
|
||||
by_src = {i.source_url: i for i in current.images}
|
||||
for image in product.images:
|
||||
match = by_src.get(image.source_url)
|
||||
if match is None:
|
||||
image_plans.append(
|
||||
ImagePlan(kind="add", source_url=image.source_url, position=image.position,
|
||||
alt_text=image.alt_text, image_id=None)
|
||||
)
|
||||
image_entries.append(
|
||||
{"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text}
|
||||
)
|
||||
continue
|
||||
image_changes: dict[str, object] = {}
|
||||
image_change_entries: list[dict] = []
|
||||
for f, before, after in (
|
||||
("position", match.position, image.position),
|
||||
("alt_text", match.alt_text, image.alt_text),
|
||||
):
|
||||
if after != before:
|
||||
image_changes[f] = after
|
||||
image_change_entries.append(_change(f, before, after))
|
||||
if image_changes:
|
||||
image_plans.append(
|
||||
ImagePlan(kind="update", source_url=image.source_url, position=image.position,
|
||||
alt_text=image.alt_text, image_id=match.id, changes=image_changes)
|
||||
)
|
||||
image_entries.append(
|
||||
{"src": image.source_url, "kind": "update", "changes": image_change_entries}
|
||||
)
|
||||
|
||||
detail: dict = {}
|
||||
if changes:
|
||||
detail["changes"] = changes
|
||||
if variant_entries:
|
||||
detail["variants"] = variant_entries
|
||||
if image_entries:
|
||||
detail["images"] = image_entries
|
||||
kind = "update" if detail else "unchanged"
|
||||
return (
|
||||
ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes,
|
||||
variant_plans=variant_plans, image_plans=image_plans),
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
def resolved_fields(fields: dict[str, object]) -> dict[str, object]:
|
||||
"""File-present fields with None (an explicit clear) resolved to the default."""
|
||||
return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()}
|
||||
|
||||
|
||||
def resolved_product_fields(product: CanonicalProduct) -> dict[str, object]:
|
||||
"""Resolved product-level fields, minus the option-name presence markers."""
|
||||
return {
|
||||
f: v for f, v in resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
|
||||
"""File-present variant fields with clears resolved. A cleared position has
|
||||
no CLEAR_DEFAULTS entry — it resets to the variant's 1-based file order
|
||||
within its product ("defaults to file order"), never to NULL."""
|
||||
resolved = resolved_fields(variant.fields)
|
||||
if "position" in resolved and resolved["position"] is None:
|
||||
resolved["position"] = file_order
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
|
||||
return {f: _json_safe(v) for f, v in resolved_variant_fields(variant, file_order).items()}
|
||||
|
||||
|
||||
def _change(field_name: str, before: object, after: object) -> dict:
|
||||
return {"field": field_name, "before": _json_safe(before), "after": _json_safe(after)}
|
||||
|
||||
|
||||
def _json_safe(value: object) -> object:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [_json_safe(v) for v in value]
|
||||
return value
|
||||
@@ -0,0 +1,41 @@
|
||||
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ProductsError(Exception):
|
||||
"""Base for products-domain errors."""
|
||||
|
||||
|
||||
class FileRejected(ProductsError):
|
||||
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
|
||||
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
|
||||
file_too_large)."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class DraftNotFound(ProductsError):
|
||||
"""No such draft for this storefront (or already discarded)."""
|
||||
|
||||
|
||||
class DraftExpired(ProductsError):
|
||||
"""The draft's validity window passed (§6.3 ~1 h)."""
|
||||
|
||||
|
||||
class PreviewStale(ProductsError):
|
||||
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
|
||||
|
||||
|
||||
class NothingToApply(ProductsError):
|
||||
"""PUC-10: no adds and no updates — confirming would be a no-op."""
|
||||
|
||||
|
||||
class RunNotFound(ProductsError):
|
||||
"""No such import run for this storefront."""
|
||||
|
||||
|
||||
class EmptyCatalog(ProductsError):
|
||||
"""PUC-9: nothing to export (no products, or none matching the status filter)."""
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
|
||||
# validation all read from this registry.
|
||||
PRODUCT_COLUMNS: dict[str, str] = {
|
||||
# column -> product field name
|
||||
"Title": "title",
|
||||
"Description": "description_html",
|
||||
"Vendor": "vendor",
|
||||
"Type": "product_type",
|
||||
"Google Product Category": "google_product_category",
|
||||
"Tags": "tags",
|
||||
"Status": "status",
|
||||
"Published": "published",
|
||||
"Option1 Name": "option1_name",
|
||||
"Option2 Name": "option2_name",
|
||||
"Option3 Name": "option3_name",
|
||||
}
|
||||
VARIANT_COLUMNS: dict[str, str] = {
|
||||
"Variant SKU": "sku",
|
||||
"Variant Barcode": "barcode",
|
||||
"Variant Price": "price",
|
||||
"Variant Cost": "cost",
|
||||
"Variant Weight": "weight",
|
||||
"Variant Weight Unit": "weight_unit",
|
||||
"Variant Volume": "volume",
|
||||
"Variant Volume Unit": "volume_unit",
|
||||
"Variant Tax ID 1": "tax_id_1",
|
||||
"Variant Tax ID 2": "tax_id_2",
|
||||
"Variant Inventory Tracker": "inventory_tracker",
|
||||
"Variant Inventory Qty": "inventory_qty",
|
||||
"Variant Position": "position",
|
||||
"Variant Image": "variant_image",
|
||||
}
|
||||
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
|
||||
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
|
||||
COMPONENT_COLUMNS = tuple(
|
||||
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
|
||||
)
|
||||
KNOWN_COLUMNS = (
|
||||
{"Handle"}
|
||||
| set(PRODUCT_COLUMNS)
|
||||
| set(VARIANT_COLUMNS)
|
||||
| set(OPTION_VALUE_COLUMNS)
|
||||
| set(IMAGE_COLUMNS)
|
||||
| set(COMPONENT_COLUMNS)
|
||||
)
|
||||
|
||||
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
|
||||
CLEAR_DEFAULTS: dict[str, object] = {
|
||||
"status": "active",
|
||||
"published": True,
|
||||
"product_type": "standalone",
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
MAX_DATA_ROWS = 5_000 # INV-18
|
||||
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Row:
|
||||
"""One CSV data row: 1-based file line number + the cells of known columns
|
||||
present in the header (column name -> raw string, possibly empty)."""
|
||||
|
||||
line_number: int
|
||||
cells: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedFile:
|
||||
dialect: str
|
||||
header: list[str]
|
||||
unknown_columns: list[str]
|
||||
rows: list[Row]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RowError:
|
||||
line_number: int
|
||||
column: str | None
|
||||
message: str
|
||||
|
||||
def as_json(self) -> dict:
|
||||
return {"line": self.line_number, "column": self.column, "message": self.message}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanonicalVariant:
|
||||
line_number: int
|
||||
options: tuple[str | None, str | None, str | None]
|
||||
# field name -> normalized value; present only for columns in the file.
|
||||
# value None == clear (reset to default/NULL).
|
||||
fields: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanonicalImage:
|
||||
line_number: int
|
||||
source_url: str
|
||||
position: int
|
||||
alt_text: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanonicalProduct:
|
||||
first_line: int
|
||||
handle: str
|
||||
title: str # "" when missing (the block then carries an error)
|
||||
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
|
||||
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
|
||||
variants: list[CanonicalVariant] = field(default_factory=list)
|
||||
images: list[CanonicalImage] = field(default_factory=list)
|
||||
errors: list[RowError] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def valid(self) -> bool:
|
||||
return not self.errors
|
||||
@@ -0,0 +1,496 @@
|
||||
"""products repo — the SQL layer for the import spine (SD-0002 §6.3 data model).
|
||||
|
||||
Owns SQL only: the catalog snapshot the diff engine reads, import draft/run
|
||||
CRUD, and the apply primitives the confirm transaction calls. Business rules
|
||||
live in service.py and diff.py — nothing here validates, diffs, commits, or
|
||||
rolls back (the confirm flow runs the apply primitives inside its own
|
||||
transaction). Every catalog/draft/run query is storefront-scoped (INV-14).
|
||||
|
||||
Dict payload conventions: functions feeding §6.4 API payloads (insert_draft,
|
||||
list_runs, get_run) return datetimes as `.isoformat()` strings; get_draft_row
|
||||
returns raw datetimes for the service's expiry check. TEXT[] columns bind/load
|
||||
as Python lists and NUMERIC loads as Decimal natively under psycopg 3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .diff import CatalogImage, CatalogProduct, CatalogVariant
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog snapshot (diff input) + dashboard counts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, CatalogProduct]:
|
||||
"""The storefront's full catalog, keyed by handle, in diff.py's snapshot shape."""
|
||||
catalog: dict[str, CatalogProduct] = {}
|
||||
by_id: dict[int, CatalogProduct] = {}
|
||||
for row in conn.execute(
|
||||
"SELECT id, handle, title, description_html, vendor, product_type,"
|
||||
" google_product_category, tags, status, published,"
|
||||
" option1_name, option2_name, option3_name"
|
||||
" FROM product WHERE storefront_id = %s",
|
||||
(storefront_id,),
|
||||
):
|
||||
product = CatalogProduct(
|
||||
id=row[0],
|
||||
handle=row[1],
|
||||
title=row[2],
|
||||
option_names=(row[10], row[11], row[12]),
|
||||
fields={
|
||||
"title": row[2],
|
||||
"description_html": row[3],
|
||||
"vendor": row[4],
|
||||
"product_type": row[5],
|
||||
"google_product_category": row[6],
|
||||
"tags": row[7],
|
||||
"status": row[8],
|
||||
"published": row[9],
|
||||
},
|
||||
variants=[],
|
||||
images=[],
|
||||
)
|
||||
catalog[product.handle] = product
|
||||
by_id[product.id] = product
|
||||
for row in conn.execute(
|
||||
"SELECT v.product_id, v.id, v.position,"
|
||||
" v.option1_value, v.option2_value, v.option3_value,"
|
||||
" v.sku, v.barcode, v.price, v.cost, v.weight, v.weight_unit,"
|
||||
" v.volume, v.volume_unit, v.tax_id_1, v.tax_id_2,"
|
||||
" v.inventory_tracker, v.inventory_qty, i.source_url"
|
||||
" FROM variant v"
|
||||
" JOIN product p ON p.id = v.product_id"
|
||||
" LEFT JOIN product_image i ON i.id = v.image_id"
|
||||
" WHERE p.storefront_id = %s"
|
||||
" ORDER BY v.product_id, v.position, v.id",
|
||||
(storefront_id,),
|
||||
):
|
||||
by_id[row[0]].variants.append(
|
||||
CatalogVariant(
|
||||
id=row[1],
|
||||
options=(row[3], row[4], row[5]),
|
||||
position=row[2],
|
||||
fields={
|
||||
"sku": row[6],
|
||||
"barcode": row[7],
|
||||
"price": row[8],
|
||||
"cost": row[9],
|
||||
"weight": row[10],
|
||||
"weight_unit": row[11],
|
||||
"volume": row[12],
|
||||
"volume_unit": row[13],
|
||||
"tax_id_1": row[14],
|
||||
"tax_id_2": row[15],
|
||||
"inventory_tracker": row[16],
|
||||
"inventory_qty": row[17],
|
||||
"variant_image": row[18],
|
||||
},
|
||||
)
|
||||
)
|
||||
for row in conn.execute(
|
||||
"SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text"
|
||||
" FROM product_image i"
|
||||
" JOIN product p ON p.id = i.product_id"
|
||||
" WHERE p.storefront_id = %s"
|
||||
" ORDER BY i.product_id, i.position, i.id",
|
||||
(storefront_id,),
|
||||
):
|
||||
by_id[row[0]].images.append(
|
||||
CatalogImage(id=row[1], source_url=row[2], position=row[3], alt_text=row[4])
|
||||
)
|
||||
return catalog
|
||||
|
||||
|
||||
_EXPORT_STATUSES = ("all", "active", "draft", "archived")
|
||||
|
||||
|
||||
def export_catalog(
|
||||
conn: psycopg.Connection, storefront_id: int, status_filter: str
|
||||
) -> list[CatalogProduct]:
|
||||
"""The storefront's catalog as an ordered snapshot list, optionally filtered
|
||||
by product status (PUC-9). Reuses load_catalog's snapshot builder; the
|
||||
catalog fits in memory (≤5k rows, INV-18). Ordered by handle for a stable,
|
||||
deterministic export."""
|
||||
catalog = load_catalog(conn, storefront_id)
|
||||
products = sorted(catalog.values(), key=lambda p: p.handle)
|
||||
if status_filter and status_filter != "all":
|
||||
products = [p for p in products if p.fields.get("status") == status_filter]
|
||||
return products
|
||||
|
||||
|
||||
def product_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||
return conn.execute(
|
||||
"SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def image_problem_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||
return conn.execute(
|
||||
"SELECT count(*) FROM product_image i"
|
||||
" JOIN product p ON p.id = i.product_id"
|
||||
" WHERE p.storefront_id = %s"
|
||||
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')",
|
||||
(storefront_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def latest_run_id(conn: psycopg.Connection, storefront_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM import_run WHERE storefront_id = %s"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
(storefront_id,),
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import drafts (preview server side, INV-11)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def insert_draft(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
account_id: int,
|
||||
file_name: str,
|
||||
dialect: str,
|
||||
file_bytes: bytes,
|
||||
summary: dict,
|
||||
records: list,
|
||||
fingerprint: str,
|
||||
unknown_columns: list[str],
|
||||
) -> dict:
|
||||
"""Create a draft (expires in 1 hour); returns the §6.4 draft payload."""
|
||||
row = conn.execute(
|
||||
"INSERT INTO import_draft"
|
||||
" (storefront_id, account_id, file_name, dialect, file_bytes,"
|
||||
" summary, records, fingerprint, unknown_columns, expires_at)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, now() + interval '1 hour')"
|
||||
" RETURNING id, expires_at",
|
||||
(
|
||||
storefront_id,
|
||||
account_id,
|
||||
file_name,
|
||||
dialect,
|
||||
file_bytes,
|
||||
Jsonb(summary),
|
||||
Jsonb(records),
|
||||
fingerprint,
|
||||
unknown_columns,
|
||||
),
|
||||
).fetchone()
|
||||
return {
|
||||
"id": row[0],
|
||||
"file_name": file_name,
|
||||
"dialect": dialect,
|
||||
"summary": summary,
|
||||
"unknown_columns": unknown_columns,
|
||||
"expires_at": row[1].isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def get_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
"SELECT id, storefront_id, account_id, file_name, dialect, file_bytes,"
|
||||
" summary, records, fingerprint, unknown_columns, expires_at, created_at"
|
||||
" FROM import_draft WHERE id = %s AND storefront_id = %s",
|
||||
(draft_id, storefront_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
columns = (
|
||||
"id",
|
||||
"storefront_id",
|
||||
"account_id",
|
||||
"file_name",
|
||||
"dialect",
|
||||
"file_bytes",
|
||||
"summary",
|
||||
"records",
|
||||
"fingerprint",
|
||||
"unknown_columns",
|
||||
"expires_at",
|
||||
"created_at",
|
||||
)
|
||||
record = dict(zip(columns, row))
|
||||
# BYTEA loads as memoryview; the service expects bytes.
|
||||
record["file_bytes"] = bytes(record["file_bytes"])
|
||||
return record
|
||||
|
||||
|
||||
def draft_records(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
draft_id: int,
|
||||
kind: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[dict]:
|
||||
"""The draft's preview records, order-preserving, optionally filtered by kind."""
|
||||
rows = conn.execute(
|
||||
"SELECT rec FROM import_draft d,"
|
||||
" jsonb_array_elements(d.records) WITH ORDINALITY AS r(rec, ord)"
|
||||
" WHERE d.id = %(draft_id)s AND d.storefront_id = %(storefront_id)s"
|
||||
" AND (%(kind)s::text IS NULL OR rec->>'kind' = %(kind)s)"
|
||||
" ORDER BY ord LIMIT %(limit)s OFFSET %(offset)s",
|
||||
{
|
||||
"draft_id": draft_id,
|
||||
"storefront_id": storefront_id,
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
def delete_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
|
||||
conn.execute(
|
||||
"DELETE FROM import_draft WHERE id = %s AND storefront_id = %s",
|
||||
(draft_id, storefront_id),
|
||||
)
|
||||
|
||||
|
||||
def sweep_expired_drafts(conn: psycopg.Connection) -> None:
|
||||
conn.execute("DELETE FROM import_draft WHERE expires_at < now()")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import runs (history, PUC-8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TERMINAL_RUN_STATUSES = ("complete", "complete_with_problems")
|
||||
|
||||
|
||||
def insert_run(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
account_id: int,
|
||||
file_name: str,
|
||||
dialect: str,
|
||||
added: int,
|
||||
updated: int,
|
||||
errored: int,
|
||||
status: str,
|
||||
) -> int:
|
||||
return conn.execute(
|
||||
"INSERT INTO import_run"
|
||||
" (storefront_id, account_id, file_name, dialect,"
|
||||
" products_added, products_updated, rows_errored, status, completed_at)"
|
||||
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END)"
|
||||
" RETURNING id",
|
||||
(
|
||||
storefront_id,
|
||||
account_id,
|
||||
file_name,
|
||||
dialect,
|
||||
added,
|
||||
updated,
|
||||
errored,
|
||||
status,
|
||||
status in _TERMINAL_RUN_STATUSES,
|
||||
),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def insert_run_errors(conn: psycopg.Connection, run_id: int, errors: list[dict]) -> None:
|
||||
"""Record per-row errors (RowError.as_json shape: line/column/message)."""
|
||||
if not errors:
|
||||
return
|
||||
with conn.cursor() as cur:
|
||||
cur.executemany(
|
||||
"INSERT INTO import_run_error (run_id, line_number, column_name, message)"
|
||||
" VALUES (%s, %s, %s, %s)",
|
||||
[(run_id, e["line"], e["column"], e["message"]) for e in errors],
|
||||
)
|
||||
|
||||
|
||||
_RUN_SELECT = (
|
||||
"SELECT r.id, r.file_name, r.dialect, r.created_at, r.completed_at, r.status,"
|
||||
" a.email, r.products_added, r.products_updated, r.rows_errored"
|
||||
" FROM import_run r JOIN account a ON a.id = r.account_id"
|
||||
)
|
||||
|
||||
|
||||
def _run_dict(row: tuple) -> dict:
|
||||
return {
|
||||
"id": row[0],
|
||||
"file_name": row[1],
|
||||
"dialect": row[2],
|
||||
"created_at": row[3].isoformat(),
|
||||
"completed_at": row[4].isoformat() if row[4] is not None else None,
|
||||
"status": row[5],
|
||||
"by": row[6],
|
||||
"products_added": row[7],
|
||||
"products_updated": row[8],
|
||||
"rows_errored": row[9],
|
||||
}
|
||||
|
||||
|
||||
def list_runs(
|
||||
conn: psycopg.Connection, storefront_id: int, limit: int, offset: int
|
||||
) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
_RUN_SELECT
|
||||
+ " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
|
||||
" LIMIT %s OFFSET %s",
|
||||
(storefront_id, limit, offset),
|
||||
).fetchall()
|
||||
return [_run_dict(row) for row in rows]
|
||||
|
||||
|
||||
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None:
|
||||
row = conn.execute(
|
||||
_RUN_SELECT + " WHERE r.storefront_id = %s AND r.id = %s",
|
||||
(storefront_id, run_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
run = _run_dict(row)
|
||||
run["errors"] = [
|
||||
{"line": line, "column": column, "message": message}
|
||||
for line, column, message in conn.execute(
|
||||
"SELECT line_number, column_name, message FROM import_run_error"
|
||||
" WHERE run_id = %s ORDER BY line_number, id",
|
||||
(run_id,),
|
||||
)
|
||||
]
|
||||
# SLICE-7 fills these; the §6.4 payload shape is stable from SLICE-5 on.
|
||||
run["image_progress"] = {"done": 0, "total": 0}
|
||||
run["image_outcomes"] = []
|
||||
return run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply primitives — called inside the confirm transaction (Task 8); no commits.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def insert_product(
|
||||
conn: psycopg.Connection,
|
||||
storefront_id: int,
|
||||
handle: str,
|
||||
resolved_fields: dict,
|
||||
option_names: tuple[str | None, str | None, str | None],
|
||||
) -> int:
|
||||
"""INSERT with only the file-present fields; absent ones take column defaults."""
|
||||
columns = ["storefront_id", "handle", "option1_name", "option2_name", "option3_name"]
|
||||
values: list[object] = [storefront_id, handle, *option_names]
|
||||
for field_name, value in resolved_fields.items():
|
||||
columns.append(field_name)
|
||||
values.append(value)
|
||||
query = sql.SQL("INSERT INTO product ({}) VALUES ({}) RETURNING id").format(
|
||||
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
||||
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||
)
|
||||
return conn.execute(query, values).fetchone()[0]
|
||||
|
||||
|
||||
def update_product(conn: psycopg.Connection, product_id: int, changed_fields: dict) -> None:
|
||||
if not changed_fields:
|
||||
return
|
||||
assignments = [
|
||||
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
|
||||
for f in changed_fields
|
||||
]
|
||||
query = sql.SQL("UPDATE product SET {}, updated_at = now() WHERE id = {}").format(
|
||||
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||
)
|
||||
conn.execute(query, [*changed_fields.values(), product_id])
|
||||
|
||||
|
||||
def insert_variant(
|
||||
conn: psycopg.Connection,
|
||||
product_id: int,
|
||||
position: int,
|
||||
options: tuple[str | None, str | None, str | None],
|
||||
resolved_fields: dict,
|
||||
image_id: int | None,
|
||||
) -> int:
|
||||
# variant_image is not a column — the caller translates it to image_id; position
|
||||
# is the explicit param. Filter both defensively.
|
||||
fields = {
|
||||
k: v for k, v in resolved_fields.items() if k != "variant_image" and k != "position"
|
||||
}
|
||||
columns = [
|
||||
"product_id",
|
||||
"position",
|
||||
"option1_value",
|
||||
"option2_value",
|
||||
"option3_value",
|
||||
"image_id",
|
||||
]
|
||||
values: list[object] = [product_id, position, *options, image_id]
|
||||
for field_name, value in fields.items():
|
||||
columns.append(field_name)
|
||||
values.append(value)
|
||||
query = sql.SQL("INSERT INTO variant ({}) VALUES ({}) RETURNING id").format(
|
||||
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
||||
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||
)
|
||||
return conn.execute(query, values).fetchone()[0]
|
||||
|
||||
|
||||
def update_variant(
|
||||
conn: psycopg.Connection,
|
||||
variant_id: int,
|
||||
changed_fields: dict,
|
||||
image_id: int | None | type(...) = ...,
|
||||
) -> None:
|
||||
"""Dynamic UPDATE; image_id's Ellipsis default means "don't touch image_id"."""
|
||||
fields = {
|
||||
k: v for k, v in changed_fields.items() if k != "variant_image"
|
||||
}
|
||||
assignments = [
|
||||
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) for f in fields
|
||||
]
|
||||
values: list[object] = list(fields.values())
|
||||
if image_id is not ...:
|
||||
assignments.append(sql.SQL("image_id = {}").format(sql.Placeholder()))
|
||||
values.append(image_id)
|
||||
if not assignments:
|
||||
return
|
||||
query = sql.SQL("UPDATE variant SET {}, updated_at = now() WHERE id = {}").format(
|
||||
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||
)
|
||||
conn.execute(query, [*values, variant_id])
|
||||
|
||||
|
||||
def get_or_create_image(
|
||||
conn: psycopg.Connection,
|
||||
product_id: int,
|
||||
source_url: str,
|
||||
position: int,
|
||||
alt_text: str | None,
|
||||
run_id: int,
|
||||
) -> int:
|
||||
"""Image identity within a product is source_url (§6.3); existing rows are
|
||||
returned untouched — diff emits explicit image update entries for position/alt."""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM product_image WHERE product_id = %s AND source_url = %s",
|
||||
(product_id, source_url),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return row[0]
|
||||
return conn.execute(
|
||||
"INSERT INTO product_image (product_id, source_url, position, alt_text, import_run_id)"
|
||||
" VALUES (%s, %s, %s, %s, %s) RETURNING id",
|
||||
(product_id, source_url, position, alt_text, run_id),
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def update_image(conn: psycopg.Connection, image_id: int, changed_fields: dict) -> None:
|
||||
"""Subset of {position, alt_text}."""
|
||||
if not changed_fields:
|
||||
return
|
||||
assignments = [
|
||||
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
|
||||
for f in changed_fields
|
||||
]
|
||||
query = sql.SQL("UPDATE product_image SET {} WHERE id = {}").format(
|
||||
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||
)
|
||||
conn.execute(query, [*changed_fields.values(), image_id])
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,119 @@
|
||||
"""Canonical serializer — CatalogProduct snapshot → canonical CSV (SD-0002 §6.5.5).
|
||||
|
||||
The export half of "one codec, two directions": this writes exactly the columns
|
||||
codec.py/validate.py parse, in a row grammar (§6.5.1) the validator regroups
|
||||
identically — so re-importing an unmodified export diffs to nothing (INV-12).
|
||||
DB-free, like diff.py: it consumes the same CatalogProduct snapshot the diff
|
||||
engine reads (repo.load_catalog), and the service streams the result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import Iterable, Iterator
|
||||
from decimal import Decimal
|
||||
|
||||
from .diff import CatalogProduct
|
||||
from .models import (
|
||||
IMAGE_COLUMNS,
|
||||
OPTION_VALUE_COLUMNS,
|
||||
PRODUCT_COLUMNS,
|
||||
VARIANT_COLUMNS,
|
||||
)
|
||||
|
||||
# Canonical column order: Handle, then product, option-value, variant, image
|
||||
# columns. A superset of everything the parser knows (models.KNOWN_COLUMNS minus
|
||||
# the #15-reserved Component columns, which export never emits).
|
||||
HEADER: list[str] = [
|
||||
"Handle",
|
||||
*PRODUCT_COLUMNS,
|
||||
*OPTION_VALUE_COLUMNS,
|
||||
*VARIANT_COLUMNS,
|
||||
*IMAGE_COLUMNS,
|
||||
]
|
||||
|
||||
# field name -> column name, inverting the model registry (the parser reads
|
||||
# column->field; the serializer writes field->column).
|
||||
_PRODUCT_FIELD_TO_COL = {field: col for col, field in PRODUCT_COLUMNS.items()}
|
||||
_VARIANT_FIELD_TO_COL = {field: col for col, field in VARIANT_COLUMNS.items()}
|
||||
|
||||
|
||||
def _cell(value: object) -> str:
|
||||
"""Serialize one value to its canonical cell text (the parse inverse)."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool):
|
||||
return "TRUE" if value else "FALSE"
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return ", ".join(str(v) for v in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def catalog_to_csv(products: Iterable[CatalogProduct]) -> Iterator[str]:
|
||||
"""Stream canonical CSV text, header first, one product block at a time."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(HEADER)
|
||||
yield _drain(buf)
|
||||
for product in products:
|
||||
for row in _product_rows(product):
|
||||
writer.writerow([row.get(col, "") for col in HEADER])
|
||||
yield _drain(buf)
|
||||
|
||||
|
||||
def _drain(buf: io.StringIO) -> str:
|
||||
text = buf.getvalue()
|
||||
buf.seek(0)
|
||||
buf.truncate(0)
|
||||
return text
|
||||
|
||||
|
||||
def _product_rows(product: CatalogProduct) -> list[dict[str, str]]:
|
||||
"""The product's CSV rows (§6.5.1 grammar): product fields + option names on
|
||||
the first row; one variant per row; images interleaved; image-only rows when
|
||||
a product has more images than variants."""
|
||||
base = {"Handle": product.handle, "Title": product.title}
|
||||
for field, col in _PRODUCT_FIELD_TO_COL.items():
|
||||
if field in product.fields:
|
||||
base[col] = _cell(product.fields[field])
|
||||
for slot, name in enumerate(product.option_names, start=1):
|
||||
if name:
|
||||
base[f"Option{slot} Name"] = name
|
||||
|
||||
count = max(len(product.variants), len(product.images))
|
||||
rows: list[dict[str, str]] = []
|
||||
for i in range(count):
|
||||
# Row 0 carries the product-level fields; later rows carry only Handle.
|
||||
row = dict(base) if i == 0 else {"Handle": product.handle}
|
||||
if i < len(product.variants):
|
||||
_write_variant(row, product, product.variants[i])
|
||||
if i < len(product.images):
|
||||
_write_image(row, product.images[i])
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _write_image(row: dict[str, str], image) -> None:
|
||||
row["Image Src"] = image.source_url
|
||||
row["Image Position"] = str(image.position)
|
||||
if image.alt_text is not None:
|
||||
row["Image Alt Text"] = image.alt_text
|
||||
|
||||
|
||||
def _write_variant(row: dict[str, str], product: CatalogProduct, variant) -> None:
|
||||
"""Fill a row's option-value + variant columns for one variant."""
|
||||
for slot, value in enumerate(variant.options, start=1):
|
||||
# Only emit an option value when the product actually has that option
|
||||
# (a no-option product's single variant carries all-NULL options).
|
||||
if product.option_names[slot - 1] and value is not None:
|
||||
row[f"Option{slot} Value"] = value
|
||||
# position is a CatalogVariant attribute, not a fields{} entry — emit it
|
||||
# explicitly. An empty Variant Position cell re-imports as "reset to file
|
||||
# order", so a non-sequential stored position would round-trip to an update
|
||||
# (INV-12). (Like _write_image, which emits its position attribute.)
|
||||
row["Variant Position"] = str(variant.position)
|
||||
for field, col in _VARIANT_FIELD_TO_COL.items():
|
||||
if field in variant.fields:
|
||||
row[col] = _cell(variant.fields[field])
|
||||
@@ -0,0 +1,263 @@
|
||||
"""products service — the import/export use-case orchestration (SD-0002 §6.5).
|
||||
|
||||
Coordinates codec → validate → diff → repo; owns transaction boundaries (repo
|
||||
never commits). Preview is read-only against catalog tables (INV-11): validation
|
||||
writes exactly one row — the import_draft. TEL events per §9.1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
|
||||
from app.platform import telemetry
|
||||
|
||||
from . import codec, diff, repo, serialize, validate
|
||||
from .errors import (
|
||||
DraftExpired,
|
||||
DraftNotFound,
|
||||
EmptyCatalog,
|
||||
NothingToApply,
|
||||
PreviewStale,
|
||||
RunNotFound,
|
||||
)
|
||||
|
||||
|
||||
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||
file_name: str, data: bytes) -> dict:
|
||||
"""Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected."""
|
||||
started = time.monotonic()
|
||||
# Commit the sweep before parsing: a FileRejected mid-parse must not roll
|
||||
# back expired-draft cleanup along with it.
|
||||
repo.sweep_expired_drafts(conn)
|
||||
conn.commit()
|
||||
parsed = codec.parse_csv(data)
|
||||
products = validate.build_products(parsed)
|
||||
catalog = repo.load_catalog(conn, storefront_id)
|
||||
diff_result = diff.compute_diff(catalog, products)
|
||||
draft = repo.insert_draft(
|
||||
conn, storefront_id, account_id, file_name, parsed.dialect, data,
|
||||
diff_result.summary, diff_result.records, diff_result.fingerprint,
|
||||
parsed.unknown_columns,
|
||||
)
|
||||
conn.commit()
|
||||
telemetry.emit(
|
||||
"import_draft_created",
|
||||
storefront_id=storefront_id,
|
||||
dialect=parsed.dialect,
|
||||
row_count=len(parsed.rows),
|
||||
adds=diff_result.summary["adds"],
|
||||
updates=diff_result.summary["updates"],
|
||||
unchanged=diff_result.summary["unchanged"],
|
||||
errors=diff_result.summary["errors"],
|
||||
unknown_columns_count=len(parsed.unknown_columns),
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return draft
|
||||
|
||||
|
||||
def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
|
||||
"""The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3)."""
|
||||
row = repo.get_draft_row(conn, storefront_id, draft_id)
|
||||
if row is None:
|
||||
raise DraftNotFound()
|
||||
if row["expires_at"] < datetime.now(timezone.utc):
|
||||
repo.delete_draft(conn, storefront_id, draft_id)
|
||||
conn.commit()
|
||||
raise DraftExpired()
|
||||
return row
|
||||
|
||||
|
||||
def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
|
||||
"""The §6.4 draft payload — never file_bytes or the full records list."""
|
||||
row = _live_draft_row(conn, storefront_id, draft_id)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"file_name": row["file_name"],
|
||||
"dialect": row["dialect"],
|
||||
"summary": row["summary"],
|
||||
"unknown_columns": row["unknown_columns"],
|
||||
"expires_at": row["expires_at"].isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int,
|
||||
kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]:
|
||||
"""The draft's preview records, paged, optionally filtered by kind (PUC-3)."""
|
||||
_live_draft_row(conn, storefront_id, draft_id)
|
||||
return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset)
|
||||
|
||||
|
||||
def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
|
||||
"""Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a)."""
|
||||
repo.delete_draft(conn, storefront_id, draft_id)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||
draft_id: int) -> int:
|
||||
"""Apply the previewed diff in one transaction (PUC-4; INV-10/11).
|
||||
|
||||
Everything is re-derived from the draft's stored file bytes against the live
|
||||
catalog; a fingerprint mismatch means the catalog drifted since preview
|
||||
(PreviewStale — the draft is kept so the merchant can re-validate). The apply
|
||||
executes the typed plan compute_diff built alongside the preview records, so
|
||||
what lands is exactly what the preview showed. rows_errored counts the
|
||||
import_run_error rows recorded (one per RowError), which is what the run
|
||||
detail's error table shows; the preview's errors tile counts error *products*.
|
||||
"""
|
||||
started = time.monotonic()
|
||||
row = _live_draft_row(conn, storefront_id, draft_id)
|
||||
parsed = codec.parse_csv(row["file_bytes"])
|
||||
products = validate.build_products(parsed)
|
||||
catalog = repo.load_catalog(conn, storefront_id)
|
||||
diff_result = diff.compute_diff(catalog, products)
|
||||
if diff_result.fingerprint != row["fingerprint"]:
|
||||
# Release the read snapshot; nothing written.
|
||||
conn.rollback()
|
||||
raise PreviewStale()
|
||||
summary_counts = diff_result.summary
|
||||
if summary_counts["adds"] + summary_counts["updates"] == 0:
|
||||
# Release the read snapshot; nothing written.
|
||||
conn.rollback()
|
||||
raise NothingToApply()
|
||||
error_rows = [
|
||||
error.as_json()
|
||||
for plan in diff_result.plan if plan.kind == "error"
|
||||
for error in plan.canonical.errors
|
||||
]
|
||||
try:
|
||||
run_id = repo.insert_run(
|
||||
conn, storefront_id, account_id, row["file_name"], row["dialect"],
|
||||
added=summary_counts["adds"], updated=summary_counts["updates"],
|
||||
errored=len(error_rows), status="complete",
|
||||
)
|
||||
for plan in diff_result.plan:
|
||||
_apply_product_plan(conn, storefront_id, plan, run_id)
|
||||
repo.insert_run_errors(conn, run_id, error_rows)
|
||||
repo.delete_draft(conn, storefront_id, draft_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
telemetry.emit(
|
||||
"import_apply_failed",
|
||||
draft_id=draft_id,
|
||||
storefront_id=storefront_id,
|
||||
error_class=type(exc).__name__,
|
||||
)
|
||||
raise
|
||||
telemetry.emit(
|
||||
"import_run_completed",
|
||||
run_id=run_id,
|
||||
storefront_id=storefront_id,
|
||||
added=summary_counts["adds"],
|
||||
updated=summary_counts["updates"],
|
||||
errored=len(error_rows),
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
)
|
||||
return run_id
|
||||
|
||||
|
||||
def _apply_product_plan(conn: psycopg.Connection, storefront_id: int,
|
||||
plan: diff.ProductPlan, run_id: int) -> None:
|
||||
"""Execute one product's plan inside the confirm transaction (no commits here)."""
|
||||
if plan.kind == "add":
|
||||
# Title is a canonical attribute, not a fields{} entry — non-error
|
||||
# products always carry one (validate guarantees it).
|
||||
product_fields = {"title": plan.canonical.title}
|
||||
product_fields.update(diff.resolved_product_fields(plan.canonical))
|
||||
product_id = repo.insert_product(
|
||||
conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names
|
||||
)
|
||||
image_ids: dict[str, int] = {}
|
||||
elif plan.kind == "update":
|
||||
product_id = plan.catalog.id
|
||||
repo.update_product(conn, product_id, plan.product_changes)
|
||||
image_ids = {image.source_url: image.id for image in plan.catalog.images}
|
||||
else:
|
||||
return
|
||||
|
||||
# Images first, so variants' variant_image URLs resolve to ids: validate puts
|
||||
# every variant_image URL into canonical.images, so each URL is in either the
|
||||
# catalog map (existing image) or the adds below.
|
||||
for image_plan in plan.image_plans:
|
||||
if image_plan.kind == "add":
|
||||
image_ids[image_plan.source_url] = repo.get_or_create_image(
|
||||
conn, product_id, image_plan.source_url, image_plan.position,
|
||||
image_plan.alt_text, run_id,
|
||||
)
|
||||
else:
|
||||
repo.update_image(conn, image_plan.image_id, image_plan.changes)
|
||||
|
||||
for variant_plan in plan.variant_plans:
|
||||
if variant_plan.kind == "add":
|
||||
fields = diff.resolved_variant_fields(variant_plan.canonical, variant_plan.file_order)
|
||||
# diff time resolved any cleared position to file order; the
|
||||
# file_order fallback covers an absent position column.
|
||||
position = fields.get("position") or variant_plan.file_order
|
||||
url = fields.get("variant_image")
|
||||
image_id = image_ids[url] if url else None
|
||||
repo.insert_variant(
|
||||
conn, product_id, position, variant_plan.canonical.options, fields, image_id
|
||||
)
|
||||
elif "variant_image" in variant_plan.changes:
|
||||
url = variant_plan.changes["variant_image"]
|
||||
repo.update_variant(
|
||||
conn, variant_plan.catalog_id, variant_plan.changes,
|
||||
image_id=image_ids[url] if url else None,
|
||||
)
|
||||
else:
|
||||
repo.update_variant(conn, variant_plan.catalog_id, variant_plan.changes)
|
||||
|
||||
|
||||
def list_runs(conn: psycopg.Connection, storefront_id: int,
|
||||
limit: int = 50, offset: int = 0) -> list[dict]:
|
||||
"""The storefront's import history, newest first (PUC-8)."""
|
||||
return repo.list_runs(conn, storefront_id, limit, offset)
|
||||
|
||||
|
||||
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
|
||||
"""One run's §6.4 detail payload, errors included."""
|
||||
run = repo.get_run(conn, storefront_id, run_id)
|
||||
if run is None:
|
||||
raise RunNotFound()
|
||||
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:
|
||||
"""The products dashboard counts (§6.4)."""
|
||||
return {
|
||||
"product_count": repo.product_count(conn, storefront_id),
|
||||
"image_problem_count": repo.image_problem_count(conn, storefront_id),
|
||||
"latest_run_id": repo.latest_run_id(conn, storefront_id),
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Row validation — ParsedFile rows → canonical products + row errors (SD-0002 §6.5.1).
|
||||
|
||||
The codec (codec.py) handles file-level gates; this module is the row-semantics
|
||||
half of the PUC-5 import spine. It groups consecutive rows sharing a Handle into
|
||||
product blocks (Shopify's grammar), normalizes product/variant/image fields, and
|
||||
records every rule violation as a merchant-language RowError. Errors never raise:
|
||||
an error poisons its whole product block (the product previews as kind="error"
|
||||
and is excluded from apply) while parsing continues so the merchant gets a
|
||||
complete accounting in one pass (BUC-1a). Description HTML is sanitized with nh3
|
||||
on the way in (INV-15).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
import nh3
|
||||
|
||||
from .models import (
|
||||
COMPONENT_COLUMNS,
|
||||
OPTION_VALUE_COLUMNS,
|
||||
PRODUCT_COLUMNS,
|
||||
VARIANT_COLUMNS,
|
||||
CanonicalImage,
|
||||
CanonicalProduct,
|
||||
CanonicalVariant,
|
||||
ParsedFile,
|
||||
Row,
|
||||
RowError,
|
||||
)
|
||||
|
||||
_HANDLE_RE = re.compile(r"^[a-z0-9-]+$")
|
||||
_STATUSES = {"draft", "active", "archived"}
|
||||
# Title is handled as an attribute, not via fields{}. Option names live in BOTH
|
||||
# .option_names (the values) and fields{} (the file-presence signal the diff
|
||||
# needs: absent column == untouched, never a clear).
|
||||
_ATTRIBUTE_COLUMNS = {"Title"}
|
||||
# Sentinel: the cell failed normalization (the error is already recorded).
|
||||
_INVALID = object()
|
||||
|
||||
|
||||
def build_products(parsed: ParsedFile) -> list[CanonicalProduct]:
|
||||
"""Group rows into product blocks and validate every §6.5.1 rule."""
|
||||
products: list[CanonicalProduct] = []
|
||||
closed_handles: set[str] = set()
|
||||
block: list[Row] = []
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal block
|
||||
if block:
|
||||
closed_handles.add(block[0].cells["Handle"])
|
||||
products.append(_build_block(block))
|
||||
block = []
|
||||
|
||||
for row in parsed.rows:
|
||||
handle = row.cells.get("Handle", "")
|
||||
if block and handle == block[0].cells["Handle"]:
|
||||
block.append(row)
|
||||
continue
|
||||
flush()
|
||||
if not handle:
|
||||
products.append(
|
||||
_error_block(row, "(missing)", RowError(row.line_number, "Handle", "a row needs a Handle"))
|
||||
)
|
||||
elif not _HANDLE_RE.match(handle):
|
||||
products.append(
|
||||
_error_block(
|
||||
row,
|
||||
handle,
|
||||
RowError(
|
||||
row.line_number,
|
||||
"Handle",
|
||||
f"'{handle}' isn't a valid handle — lowercase letters, numbers, and dashes only",
|
||||
),
|
||||
)
|
||||
)
|
||||
elif handle in closed_handles:
|
||||
products.append(
|
||||
_error_block(
|
||||
row,
|
||||
handle,
|
||||
RowError(
|
||||
row.line_number,
|
||||
"Handle",
|
||||
f"rows for '{handle}' must be consecutive — it already appeared earlier in the file",
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
block = [row]
|
||||
flush()
|
||||
return products
|
||||
|
||||
|
||||
def all_errors(products: list[CanonicalProduct]) -> list[RowError]:
|
||||
return [error for product in products for error in product.errors]
|
||||
|
||||
|
||||
def _error_block(row: Row, handle: str, error: RowError) -> CanonicalProduct:
|
||||
return CanonicalProduct(first_line=row.line_number, handle=handle, title="", errors=[error])
|
||||
|
||||
|
||||
def _build_block(rows: list[Row]) -> CanonicalProduct:
|
||||
first = rows[0]
|
||||
handle = first.cells["Handle"]
|
||||
errors: list[RowError] = []
|
||||
|
||||
title = first.cells.get("Title", "")
|
||||
if not title:
|
||||
errors.append(RowError(first.line_number, "Title", f"'{handle}' is missing its Title"))
|
||||
|
||||
option_names = tuple(first.cells.get(f"Option{n} Name") or None for n in (1, 2, 3))
|
||||
has_options = any(option_names)
|
||||
|
||||
# Product-level fields come from the first row only; empty cell == clear (None).
|
||||
fields: dict[str, object] = {}
|
||||
for column, field_name in PRODUCT_COLUMNS.items():
|
||||
if column in _ATTRIBUTE_COLUMNS or column not in first.cells:
|
||||
continue
|
||||
cell = first.cells[column]
|
||||
if not cell:
|
||||
fields[field_name] = None
|
||||
continue
|
||||
value = _product_value(first.line_number, column, field_name, cell, errors)
|
||||
if value is not _INVALID:
|
||||
fields[field_name] = value
|
||||
|
||||
variants: list[CanonicalVariant] = []
|
||||
images: list[CanonicalImage] = []
|
||||
seen_combos: set[tuple[str | None, str | None, str | None]] = set()
|
||||
|
||||
for index, row in enumerate(rows):
|
||||
cells = row.cells
|
||||
line = row.line_number
|
||||
|
||||
for column in COMPONENT_COLUMNS:
|
||||
if cells.get(column):
|
||||
errors.append(
|
||||
RowError(line, column, "kits arrive in a coming release — leave the Component columns empty")
|
||||
)
|
||||
|
||||
has_image = bool(cells.get("Image Src"))
|
||||
if has_image:
|
||||
_collect_image(row, images, errors)
|
||||
|
||||
# A row carries a variant iff any option value / Variant-* cell is filled;
|
||||
# the first row of a no-option product always carries the single variant.
|
||||
carries_variant = (
|
||||
any(cells.get(c) for c in OPTION_VALUE_COLUMNS)
|
||||
or any(cells.get(c) for c in VARIANT_COLUMNS)
|
||||
or (index == 0 and not has_options)
|
||||
)
|
||||
if carries_variant:
|
||||
options = tuple(cells.get(c) or None for c in OPTION_VALUE_COLUMNS)
|
||||
for n in (1, 2, 3):
|
||||
value, name = options[n - 1], option_names[n - 1]
|
||||
if value and not name:
|
||||
errors.append(
|
||||
RowError(
|
||||
line,
|
||||
f"Option{n} Value",
|
||||
f"Option{n} Value given but the product has no Option{n} Name",
|
||||
)
|
||||
)
|
||||
elif name and not value:
|
||||
errors.append(
|
||||
RowError(
|
||||
line,
|
||||
f"Option{n} Value",
|
||||
f"this variant is missing its Option{n} Value ('{name}')",
|
||||
)
|
||||
)
|
||||
if not has_options:
|
||||
if variants:
|
||||
errors.append(RowError(line, None, "a product without options can have only one variant"))
|
||||
elif options in seen_combos:
|
||||
errors.append(
|
||||
RowError(line, None, f"duplicate variant — '{handle}' already has a variant with these options")
|
||||
)
|
||||
seen_combos.add(options)
|
||||
|
||||
variant_fields: dict[str, object] = {}
|
||||
for column, field_name in VARIANT_COLUMNS.items():
|
||||
if column not in cells:
|
||||
continue
|
||||
cell = cells[column]
|
||||
if not cell:
|
||||
variant_fields[field_name] = None
|
||||
continue
|
||||
value = _variant_value(line, column, field_name, cell, errors)
|
||||
if value is _INVALID:
|
||||
continue
|
||||
variant_fields[field_name] = value
|
||||
if field_name == "variant_image" and cell not in {i.source_url for i in images}:
|
||||
images.append(
|
||||
CanonicalImage(line_number=line, source_url=cell, position=len(images) + 1, alt_text=None)
|
||||
)
|
||||
variants.append(CanonicalVariant(line_number=line, options=options, fields=variant_fields))
|
||||
elif index > 0 and not has_image:
|
||||
errors.append(RowError(line, None, "this row has no variant or image data"))
|
||||
|
||||
return CanonicalProduct(
|
||||
first_line=first.line_number,
|
||||
handle=handle,
|
||||
title=title,
|
||||
option_names=option_names,
|
||||
fields=fields,
|
||||
variants=variants,
|
||||
images=images,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def _collect_image(row: Row, images: list[CanonicalImage], errors: list[RowError]) -> None:
|
||||
cells = row.cells
|
||||
source_url = cells["Image Src"]
|
||||
position_cell = cells.get("Image Position", "")
|
||||
position: int | None = None
|
||||
if position_cell:
|
||||
try:
|
||||
position = int(position_cell)
|
||||
if position < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
position = None
|
||||
errors.append(RowError(row.line_number, "Image Position", f"'{position_cell}' is not a position"))
|
||||
# Dedupe by source URL within the block — first occurrence wins.
|
||||
if source_url in {i.source_url for i in images}:
|
||||
return
|
||||
images.append(
|
||||
CanonicalImage(
|
||||
line_number=row.line_number,
|
||||
source_url=source_url,
|
||||
position=position if position is not None else len(images) + 1,
|
||||
alt_text=cells.get("Image Alt Text") or None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _product_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
|
||||
if field_name == "tags":
|
||||
return [tag.strip() for tag in cell.split(",") if tag.strip()]
|
||||
if field_name == "status":
|
||||
status = cell.lower()
|
||||
if status not in _STATUSES:
|
||||
errors.append(RowError(line, column, f"'{cell}' is not a status — use draft, active, or archived"))
|
||||
return _INVALID
|
||||
return status
|
||||
if field_name == "published":
|
||||
flag = cell.upper()
|
||||
if flag not in ("TRUE", "FALSE"):
|
||||
errors.append(RowError(line, column, f"'{cell}' is not TRUE or FALSE"))
|
||||
return _INVALID
|
||||
return flag == "TRUE"
|
||||
if field_name == "description_html":
|
||||
return nh3.clean(cell)
|
||||
if field_name == "product_type":
|
||||
if cell != "standalone":
|
||||
errors.append(RowError(line, column, "kits arrive in a coming release — Type must be 'standalone'"))
|
||||
return _INVALID
|
||||
return cell
|
||||
return cell
|
||||
|
||||
|
||||
def _variant_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
|
||||
if field_name in ("price", "cost"):
|
||||
return _decimal_or_error(line, column, cell, f"'{cell}' is not a price", errors)
|
||||
if field_name in ("weight", "volume"):
|
||||
return _decimal_or_error(line, column, cell, f"'{cell}' is not a number", errors)
|
||||
if field_name == "inventory_qty":
|
||||
try:
|
||||
quantity = int(cell)
|
||||
if quantity < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(RowError(line, column, f"'{cell}' is not a whole number"))
|
||||
return _INVALID
|
||||
return quantity
|
||||
if field_name == "position":
|
||||
try:
|
||||
position = int(cell)
|
||||
if position < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
errors.append(RowError(line, column, f"'{cell}' is not a position"))
|
||||
return _INVALID
|
||||
return position
|
||||
return cell
|
||||
|
||||
|
||||
def _decimal_or_error(line: int, column: str, cell: str, message: str, errors: list[RowError]) -> object:
|
||||
try:
|
||||
value = Decimal(cell)
|
||||
if not value.is_finite() or value < 0:
|
||||
raise InvalidOperation
|
||||
except InvalidOperation:
|
||||
errors.append(RowError(line, column, message))
|
||||
return _INVALID
|
||||
return value
|
||||
+200
-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
|
||||
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, StreamingResponse
|
||||
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,181 @@ 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/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)."""
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Structured log-event telemetry (SD-0002 §9.1). One JSON object per event on the
|
||||
`ecomm.telemetry` logger — counts and durations only; never file names, URLs,
|
||||
catalog content, or secret bytes (§6.3-handbook)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger("ecomm.telemetry")
|
||||
|
||||
|
||||
def emit(event: str, **fields: object) -> None:
|
||||
_logger.info(json.dumps({"event": event, **fields}, sort_keys=True, default=str))
|
||||
@@ -0,0 +1,130 @@
|
||||
-- 0002_products.sql — SD-0002 §6.3 data model (SLICE-5). Forward-only (INV-7):
|
||||
-- never edit once merged; add a new numbered migration.
|
||||
|
||||
-- product — one catalog product per (storefront, handle) (INV-13/14). Option *names*
|
||||
-- live here; product_type's kit values are schema-open but a service-layer rule
|
||||
-- rejects non-'standalone' until #15 (same pattern as INV-4).
|
||||
CREATE TABLE product (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
handle TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description_html TEXT,
|
||||
vendor TEXT,
|
||||
product_type TEXT NOT NULL DEFAULT 'standalone'
|
||||
CHECK (product_type IN ('standalone', 'kit_virtual', 'kit_assembled')),
|
||||
google_product_category TEXT,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
|
||||
published BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
option1_name TEXT,
|
||||
option2_name TEXT,
|
||||
option3_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX product_handle_key ON product (storefront_id, handle); -- INV-13
|
||||
|
||||
-- variant — one purchasable form, identified by its option-value combo (INV-13).
|
||||
-- SKU is indexed data, never identity. image_id FK is added after product_image.
|
||||
CREATE TABLE variant (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_id BIGINT NOT NULL REFERENCES product (id),
|
||||
position INTEGER NOT NULL,
|
||||
option1_value TEXT,
|
||||
option2_value TEXT,
|
||||
option3_value TEXT,
|
||||
sku TEXT,
|
||||
barcode TEXT,
|
||||
price NUMERIC,
|
||||
cost NUMERIC,
|
||||
weight NUMERIC,
|
||||
weight_unit TEXT,
|
||||
volume NUMERIC,
|
||||
volume_unit TEXT,
|
||||
tax_id_1 TEXT,
|
||||
tax_id_2 TEXT,
|
||||
inventory_tracker TEXT,
|
||||
inventory_qty INTEGER,
|
||||
image_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- Postgres 16 (compose + Cloud SQL pin): NULLS NOT DISTINCT makes the all-NULL
|
||||
-- no-option combo unique too (INV-13).
|
||||
CREATE UNIQUE INDEX variant_option_combo_key
|
||||
ON variant (product_id, option1_value, option2_value, option3_value)
|
||||
NULLS NOT DISTINCT;
|
||||
CREATE INDEX variant_sku_idx ON variant (sku);
|
||||
|
||||
-- product_image — identity within a product is source_url (§6.3); bytes live in
|
||||
-- object storage from SLICE-7 (keys nullable until fetched). status starts 'pending';
|
||||
-- SLICE-5 stubs the fetch phase so rows simply stay pending.
|
||||
CREATE TABLE product_image (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
product_id BIGINT NOT NULL REFERENCES product (id),
|
||||
position INTEGER NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
alt_text TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'fetched', 'rejected_low_res', 'rejected_not_image', 'failed')),
|
||||
failure_reason TEXT,
|
||||
key_original TEXT,
|
||||
key_thumb TEXT,
|
||||
key_card TEXT,
|
||||
key_detail TEXT,
|
||||
import_run_id BIGINT,
|
||||
fetched_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE UNIQUE INDEX product_image_src_key ON product_image (product_id, source_url);
|
||||
ALTER TABLE variant
|
||||
ADD CONSTRAINT variant_image_fk FOREIGN KEY (image_id) REFERENCES product_image (id);
|
||||
|
||||
-- import_draft — the preview's server side (INV-11). file_bytes is the SLICE-5
|
||||
-- interim home for the upload (objectstore key from SLICE-7). Deleted outright on
|
||||
-- cancel/expiry — drafts never appear in history (PUC-3a).
|
||||
CREATE TABLE import_draft (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
account_id BIGINT NOT NULL REFERENCES account (id),
|
||||
file_name TEXT NOT NULL,
|
||||
dialect TEXT NOT NULL,
|
||||
file_bytes BYTEA NOT NULL,
|
||||
summary JSONB NOT NULL,
|
||||
records JSONB NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
unknown_columns TEXT[] NOT NULL DEFAULT '{}',
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- import_run — the durable record of one confirmed import (PUC-8); created only at
|
||||
-- confirm (PUC-4).
|
||||
CREATE TABLE import_run (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||
account_id BIGINT NOT NULL REFERENCES account (id),
|
||||
file_name TEXT NOT NULL,
|
||||
dialect TEXT NOT NULL,
|
||||
products_added INTEGER NOT NULL,
|
||||
products_updated INTEGER NOT NULL,
|
||||
rows_errored INTEGER NOT NULL,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('applying', 'fetching_images', 'complete', 'complete_with_problems')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX import_run_history_idx ON import_run (storefront_id, created_at DESC);
|
||||
|
||||
-- import_run_error — one row per rejected CSV row (PUC-5), merchant-language message.
|
||||
CREATE TABLE import_run_error (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL REFERENCES import_run (id),
|
||||
line_number INTEGER NOT NULL,
|
||||
column_name TEXT,
|
||||
message TEXT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE product_image
|
||||
ADD CONSTRAINT product_image_run_fk FOREIGN KEY (import_run_id) REFERENCES import_run (id);
|
||||
@@ -5,3 +5,5 @@ psycopg[binary]>=3.1
|
||||
psycopg-pool>=3.2
|
||||
pytest>=8.0
|
||||
import-linter>=2.0
|
||||
nh3>=0.2
|
||||
python-multipart>=0.0.9
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.platform import db
|
||||
|
||||
@@ -12,10 +13,10 @@ def _table_names(conn) -> set[str]:
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
def test_migrate_from_empty_applies_0001(fresh_db_url):
|
||||
def test_migrate_from_empty_applies_all(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
applied = db.migrate(conn)
|
||||
assert applied == ["0001_init.sql"]
|
||||
assert applied == ["0001_init.sql", "0002_products.sql"]
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
assert _TABLES.issubset(_table_names(conn))
|
||||
|
||||
@@ -41,3 +42,24 @@ def test_membership_has_no_unique_account_constraint(fresh_db_url):
|
||||
).fetchall()
|
||||
defs = " ".join(r[0] for r in rows).lower()
|
||||
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
|
||||
|
||||
|
||||
def test_0002_products_tables_exist(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
for table in ("product", "variant", "product_image", "import_draft", "import_run", "import_run_error"):
|
||||
assert conn.execute("SELECT to_regclass(%s)", (f"public.{table}",)).fetchone()[0] == table
|
||||
|
||||
|
||||
def test_0002_variant_option_combo_unique_treats_nulls_as_equal(fresh_db_url):
|
||||
# INV-13: the no-option product's single variant has NULL option values; a second
|
||||
# all-NULL combo must collide (NULLS NOT DISTINCT).
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
sf = conn.execute("INSERT INTO storefront (name) VALUES ('s') RETURNING id").fetchone()[0]
|
||||
pid = conn.execute(
|
||||
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'h','T') RETURNING id", (sf,)
|
||||
).fetchone()[0]
|
||||
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 1)", (pid,))
|
||||
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 2)", (pid,))
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""§6.5.1 file-level codec — parse, caps, required columns (PUC-5a fixtures)."""
|
||||
import pytest
|
||||
|
||||
from app.domains.products import FileRejected
|
||||
from app.domains.products.codec import parse_csv
|
||||
|
||||
|
||||
def _csv(*lines: str) -> bytes:
|
||||
return ("\n".join(lines) + "\n").encode()
|
||||
|
||||
|
||||
GOOD = _csv(
|
||||
"Handle,Title,Variant Price,Bogus Column",
|
||||
"moon-mug,Moon Mug,18.00,x",
|
||||
"star-tee,Star Tee,24.00,y",
|
||||
)
|
||||
|
||||
|
||||
def test_parses_header_rows_and_unknown_columns():
|
||||
parsed = parse_csv(GOOD)
|
||||
assert parsed.dialect == "canonical"
|
||||
assert parsed.unknown_columns == ["Bogus Column"]
|
||||
assert [r.line_number for r in parsed.rows] == [2, 3]
|
||||
assert parsed.rows[0].cells["Handle"] == "moon-mug"
|
||||
assert parsed.rows[0].cells["Variant Price"] == "18.00"
|
||||
assert "Bogus Column" not in parsed.rows[0].cells
|
||||
|
||||
|
||||
def test_bom_tolerated():
|
||||
parsed = parse_csv(b"\xef\xbb\xbf" + GOOD)
|
||||
assert parsed.rows[0].cells["Handle"] == "moon-mug"
|
||||
|
||||
|
||||
def test_quoted_cells_rfc4180():
|
||||
parsed = parse_csv(_csv("Handle,Title,Tags", 'mug,"The ""Best"" Mug","a, b"'))
|
||||
assert parsed.rows[0].cells["Title"] == 'The "Best" Mug'
|
||||
assert parsed.rows[0].cells["Tags"] == "a, b"
|
||||
|
||||
|
||||
def test_empty_rows_skipped_short_rows_padded():
|
||||
parsed = parse_csv(_csv("Handle,Title,Vendor", "mug,Mug", "", ",,", "tee,Tee,Acme"))
|
||||
assert [r.cells["Handle"] for r in parsed.rows] == ["mug", "tee"]
|
||||
assert parsed.rows[0].cells["Vendor"] == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data,code",
|
||||
[
|
||||
(b"\xff\xfe\x00garbage\x00", "not_csv"),
|
||||
(b"", "not_csv"),
|
||||
(_csv("Title,Vendor", "Mug,Acme"), "missing_required_column"),
|
||||
(_csv("Handle,Vendor", "mug,Acme"), "missing_required_column"),
|
||||
(
|
||||
_csv("Handle,Title", *(f"h{i},T{i}" for i in range(5001))),
|
||||
"too_many_rows",
|
||||
),
|
||||
(b"Handle,Title\n" + b"x" * (10 * 1024 * 1024), "file_too_large"),
|
||||
],
|
||||
)
|
||||
def test_file_level_rejections(data, code):
|
||||
with pytest.raises(FileRejected) as exc:
|
||||
parse_csv(data)
|
||||
assert exc.value.code == code
|
||||
|
||||
|
||||
def test_missing_column_message_names_the_column():
|
||||
with pytest.raises(FileRejected) as exc:
|
||||
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
|
||||
assert "'Title'" in exc.value.message
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8)."""
|
||||
from decimal import Decimal
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
from app.domains.products.diff import (
|
||||
CatalogImage, CatalogProduct, CatalogVariant, compute_diff,
|
||||
)
|
||||
from app.domains.products.validate import build_products
|
||||
|
||||
|
||||
def _canon(*lines: str):
|
||||
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
||||
|
||||
|
||||
def _catalog_mug(**overrides):
|
||||
fields = {
|
||||
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": ["kitchen"], "status": "active", "published": True,
|
||||
} | overrides
|
||||
return {
|
||||
"moon-mug": CatalogProduct(
|
||||
id=1, handle="moon-mug", title=fields["title"],
|
||||
option_names=(None, None, None), fields=fields,
|
||||
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
|
||||
fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"),
|
||||
"cost": None, "weight": None, "weight_unit": None,
|
||||
"volume": None, "volume_unit": None, "tax_id_1": None,
|
||||
"tax_id_2": None, "inventory_tracker": None,
|
||||
"inventory_qty": 40, "variant_image": None})],
|
||||
images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src"
|
||||
MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg'
|
||||
|
||||
|
||||
def test_new_handle_classifies_add():
|
||||
diff = compute_diff({}, _canon(HEADER, MUG_ROW))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1
|
||||
assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
assert rec["detail"]["set"]["status"] == "active"
|
||||
|
||||
|
||||
def test_identical_file_classifies_unchanged():
|
||||
diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
assert diff.summary["unchanged"] == 1
|
||||
|
||||
|
||||
def test_changed_price_classifies_update_with_before_after():
|
||||
row = MUG_ROW.replace("18.00", "21.00")
|
||||
diff = compute_diff(_catalog_mug(), _canon(HEADER, row))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
[vchange] = rec["detail"]["variants"]
|
||||
assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"]
|
||||
|
||||
|
||||
def test_absent_column_untouched_empty_cell_clears():
|
||||
# Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change).
|
||||
diff = compute_diff(
|
||||
_catalog_mug(),
|
||||
_canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
|
||||
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
|
||||
)
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
|
||||
|
||||
def test_empty_cell_clear_shows_in_diff():
|
||||
# Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1).
|
||||
diff = compute_diff(
|
||||
_catalog_mug(),
|
||||
_canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
|
||||
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
|
||||
)
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"]
|
||||
|
||||
|
||||
def test_new_option_combo_is_variant_add_existing_untouched():
|
||||
catalog = _catalog_mug()
|
||||
diff = compute_diff(
|
||||
catalog,
|
||||
_canon("Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
||||
"moon-mug,Moon Mug,Size,Large,25.00"),
|
||||
)
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
kinds = [v.get("kind") for v in rec["detail"]["variants"]]
|
||||
assert "add" in kinds
|
||||
|
||||
|
||||
POSITION_HEADER = HEADER + ",Variant Position"
|
||||
|
||||
|
||||
def test_matching_variant_position_column_classifies_unchanged():
|
||||
# position is an attribute on CatalogVariant (not in fields{}); the compare
|
||||
# must read it from there, not invent a before:None.
|
||||
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",1"))
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
|
||||
|
||||
def test_changed_variant_position_reports_honest_before():
|
||||
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",2"))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
[ventry] = rec["detail"]["variants"]
|
||||
assert {"field": "position", "before": 1, "after": 2} in ventry["changes"]
|
||||
|
||||
|
||||
def test_blank_position_cell_resolves_to_file_order_unchanged():
|
||||
# A present-but-empty Variant Position cell resets to file order (the spec's
|
||||
# "defaults to file order"), never to NULL — here file order matches the
|
||||
# catalog position, so nothing changes.
|
||||
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ","))
|
||||
assert diff.records[0]["kind"] == "unchanged"
|
||||
|
||||
|
||||
def test_blank_position_cell_updates_to_file_order():
|
||||
catalog = _catalog_mug()
|
||||
catalog["moon-mug"].variants[0].position = 2
|
||||
diff = compute_diff(catalog, _canon(POSITION_HEADER, MUG_ROW + ","))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "update"
|
||||
[ventry] = rec["detail"]["variants"]
|
||||
assert {"field": "position", "before": 2, "after": 1} in ventry["changes"]
|
||||
|
||||
|
||||
def test_error_product_classifies_error():
|
||||
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
|
||||
[rec] = diff.records
|
||||
assert rec["kind"] == "error"
|
||||
assert rec["detail"]["errors"][0]["column"] == "Variant Price"
|
||||
|
||||
|
||||
def test_fingerprint_stable_and_drift_sensitive():
|
||||
d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||
d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||
d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW))
|
||||
assert d1.fingerprint == d2.fingerprint
|
||||
assert d1.fingerprint != d3.fingerprint
|
||||
@@ -0,0 +1,135 @@
|
||||
"""§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
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Export: status-filtered catalog snapshot + the streamed service (PUC-9, TEL-3)."""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import products
|
||||
from app.domains.products import repo
|
||||
from app.platform import db
|
||||
|
||||
CSV = (
|
||||
b"Handle,Title,Vendor,Status,Variant Price\n"
|
||||
b"active-mug,Active Mug,Acme,active,18.00\n"
|
||||
b"draft-tee,Draft Tee,Acme,draft,24.00\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def merchant(migrated_conn):
|
||||
acct = migrated_conn.execute(
|
||||
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||
sf = migrated_conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||
migrated_conn.execute(
|
||||
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||
migrated_conn.commit()
|
||||
return {"account_id": acct, "storefront_id": sf}
|
||||
|
||||
|
||||
def _seed(conn, merchant):
|
||||
draft = products.import_validate(conn, merchant["storefront_id"], merchant["account_id"], "c.csv", CSV)
|
||||
products.confirm_draft(conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
|
||||
|
||||
def test_export_all_returns_both(migrated_conn, merchant):
|
||||
_seed(migrated_conn, merchant)
|
||||
snap = repo.export_catalog(migrated_conn, merchant["storefront_id"], "all")
|
||||
assert {p.handle for p in snap} == {"active-mug", "draft-tee"}
|
||||
|
||||
|
||||
def test_export_status_filter(migrated_conn, merchant):
|
||||
_seed(migrated_conn, merchant)
|
||||
active = repo.export_catalog(migrated_conn, merchant["storefront_id"], "active")
|
||||
assert [p.handle for p in active] == ["active-mug"]
|
||||
draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft")
|
||||
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]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed),
|
||||
apply transactionality (§6.8), TEL-6."""
|
||||
import json
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import products
|
||||
from app.domains.products import repo, service
|
||||
from app.platform import db
|
||||
|
||||
CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n"
|
||||
CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
def _merchant(conn, email="m@example.com", shop="Shop"):
|
||||
acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0]
|
||||
sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0]
|
||||
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||
conn.commit()
|
||||
return acct, sf
|
||||
|
||||
|
||||
def _import(conn, acct, sf, data):
|
||||
d = products.import_validate(conn, sf, acct, "f.csv", data)
|
||||
return products.confirm_draft(conn, sf, acct, d["id"])
|
||||
|
||||
|
||||
def test_inv10_partial_file_never_deletes(migrated_conn):
|
||||
acct, sf = _merchant(migrated_conn)
|
||||
_import(migrated_conn, acct, sf, CSV_A)
|
||||
before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||
_import(migrated_conn, acct, sf, CSV_PARTIAL)
|
||||
after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||
assert after >= before == 2
|
||||
|
||||
|
||||
def test_inv14_two_storefronts_zero_bleed(migrated_conn):
|
||||
acct1, sf1 = _merchant(migrated_conn)
|
||||
acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other")
|
||||
_import(migrated_conn, acct1, sf1, CSV_A)
|
||||
assert products.summary(migrated_conn, sf2)["product_count"] == 0
|
||||
assert products.list_runs(migrated_conn, sf2) == []
|
||||
_import(migrated_conn, acct2, sf2, CSV_A)
|
||||
assert products.summary(migrated_conn, sf2)["product_count"] == 2
|
||||
run1 = products.list_runs(migrated_conn, sf1)[0]
|
||||
with pytest.raises(products.RunNotFound):
|
||||
products.get_run(migrated_conn, sf2, run1["id"])
|
||||
|
||||
|
||||
def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog):
|
||||
acct, sf = _merchant(migrated_conn)
|
||||
d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A)
|
||||
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("mid-apply crash")
|
||||
monkeypatch.setattr(service.repo, "insert_run_errors", boom)
|
||||
lg = logging.getLogger("ecomm")
|
||||
prior = lg.propagate
|
||||
lg.propagate = True
|
||||
try:
|
||||
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||
with pytest.raises(RuntimeError):
|
||||
products.confirm_draft(migrated_conn, sf, acct, d["id"])
|
||||
finally:
|
||||
lg.propagate = prior
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1
|
||||
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||
assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events)
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Canonical serializer + INV-12 round-trip lock (SD-0002 §6.5.5, §6.8)."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.domains.products import serialize
|
||||
from app.domains.products.diff import CatalogImage, CatalogProduct, CatalogVariant
|
||||
|
||||
|
||||
def _product(**kw) -> CatalogProduct:
|
||||
"""A minimal no-option catalog product (one all-NULL variant)."""
|
||||
base = dict(
|
||||
id=1, handle="moon-mug", title="Moon Mug",
|
||||
option_names=(None, None, None),
|
||||
fields={
|
||||
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": [], "status": "active", "published": True,
|
||||
},
|
||||
variants=[CatalogVariant(
|
||||
id=1, options=(None, None, None), position=1,
|
||||
fields={"sku": "WG-MUG", "barcode": None, "price": None, "cost": None,
|
||||
"weight": None, "weight_unit": None, "volume": None,
|
||||
"volume_unit": None, "tax_id_1": None, "tax_id_2": None,
|
||||
"inventory_tracker": None, "inventory_qty": None,
|
||||
"variant_image": None})],
|
||||
images=[],
|
||||
)
|
||||
base.update(kw)
|
||||
return CatalogProduct(**base)
|
||||
|
||||
|
||||
def _rows(products) -> list[dict]:
|
||||
text = "".join(serialize.catalog_to_csv(products))
|
||||
return list(csv.DictReader(io.StringIO(text)))
|
||||
|
||||
|
||||
def test_header_is_full_canonical_set():
|
||||
text = "".join(serialize.catalog_to_csv([_product()]))
|
||||
header = next(csv.reader(io.StringIO(text)))
|
||||
# Handle + Title first; every known column present exactly once.
|
||||
assert header[0] == "Handle"
|
||||
assert "Title" in header and "Variant SKU" in header and "Image Src" in header
|
||||
assert len(header) == len(set(header))
|
||||
|
||||
|
||||
def test_single_no_option_product_one_row():
|
||||
rows = _rows([_product()])
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["Handle"] == "moon-mug"
|
||||
assert rows[0]["Title"] == "Moon Mug"
|
||||
assert rows[0]["Variant SKU"] == "WG-MUG"
|
||||
# A no-option product emits no option values.
|
||||
assert rows[0]["Option1 Value"] == ""
|
||||
|
||||
|
||||
def _star_tee() -> CatalogProduct:
|
||||
"""A 3-variant, 2-image product (the sample.csv shape)."""
|
||||
return CatalogProduct(
|
||||
id=2, handle="star-tee", title="Star Tee",
|
||||
option_names=("Size", "Color", None),
|
||||
fields={"title": "Star Tee", "description_html": None, "vendor": "Acme",
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": ["apparel", "tees"], "status": "active", "published": True},
|
||||
variants=[
|
||||
CatalogVariant(id=10, options=("S", "Indigo", None), position=1,
|
||||
fields={"sku": "WG-TEE-S", "variant_image": None}),
|
||||
CatalogVariant(id=11, options=("M", "Indigo", None), position=2,
|
||||
fields={"sku": "WG-TEE-M", "variant_image": None}),
|
||||
CatalogVariant(id=12, options=("L", "Indigo", None), position=3,
|
||||
fields={"sku": "WG-TEE-L", "variant_image": None}),
|
||||
],
|
||||
images=[
|
||||
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text="front"),
|
||||
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text="back"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_multivariant_with_images_interleaves():
|
||||
rows = _rows([_star_tee()])
|
||||
assert len(rows) == 3 # max(3 variants, 2 images)
|
||||
# Product-level fields only on the first row.
|
||||
assert rows[0]["Title"] == "Star Tee" and rows[1]["Title"] == ""
|
||||
assert rows[0]["Tags"] == "apparel, tees" and rows[1]["Tags"] == ""
|
||||
# Option names on row 0; option values on every variant row.
|
||||
assert rows[0]["Option1 Name"] == "Size" and rows[1]["Option1 Name"] == ""
|
||||
assert [r["Option1 Value"] for r in rows] == ["S", "M", "L"]
|
||||
assert [r["Variant SKU"] for r in rows] == ["WG-TEE-S", "WG-TEE-M", "WG-TEE-L"]
|
||||
# Two images on the first two rows; third row has no image.
|
||||
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", ""]
|
||||
assert [r["Image Position"] for r in rows] == ["1", "2", ""]
|
||||
|
||||
|
||||
def test_more_images_than_variants_emits_image_only_rows():
|
||||
p = _product(images=[
|
||||
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text=None),
|
||||
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text=None),
|
||||
CatalogImage(id=3, source_url="https://x/c.jpg", position=3, alt_text=None),
|
||||
])
|
||||
rows = _rows([p])
|
||||
assert len(rows) == 3 # 1 variant, 3 images
|
||||
assert rows[0]["Variant SKU"] == "WG-MUG"
|
||||
assert rows[1]["Variant SKU"] == "" and rows[1]["Handle"] == "moon-mug"
|
||||
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"]
|
||||
|
||||
|
||||
import random
|
||||
|
||||
from app.domains.products import codec, diff, validate
|
||||
|
||||
|
||||
def _gen_catalog(seed: int) -> dict:
|
||||
"""A deterministic random text-field catalog, in load_catalog()'s shape."""
|
||||
rng = random.Random(seed)
|
||||
words = ["moon", "star", "river", "cloud", "ember", "fern", "slate", "wave"]
|
||||
catalog: dict[str, CatalogProduct] = {}
|
||||
n_products = rng.randint(1, 6)
|
||||
for pid in range(1, n_products + 1):
|
||||
handle = "-".join(rng.sample(words, rng.randint(1, 3))) + f"-{pid}"
|
||||
if handle in catalog:
|
||||
continue
|
||||
has_opts = rng.random() < 0.6
|
||||
option_names = ("Size", "Color", None) if has_opts else (None, None, None)
|
||||
tags = rng.sample(["apparel", "kitchen", "sale", "new"], rng.randint(0, 3))
|
||||
fields = {
|
||||
"title": f"{handle.title()} Thing",
|
||||
"description_html": rng.choice([None, "<p>hi</p>"]),
|
||||
"vendor": rng.choice([None, "Acme", "Wiggle Goods"]),
|
||||
"product_type": "standalone",
|
||||
"google_product_category": rng.choice([None, "Home & Garden"]),
|
||||
"tags": tags,
|
||||
"status": rng.choice(["draft", "active", "archived"]),
|
||||
"published": rng.choice([True, False]),
|
||||
}
|
||||
variants = []
|
||||
if has_opts:
|
||||
sizes = rng.sample(["S", "M", "L", "XL"], rng.randint(1, 4))
|
||||
for vi, size in enumerate(sizes, start=1):
|
||||
# Non-sequential positions (×10) so a serializer that drops the
|
||||
# stored position and lets re-import default to file order is
|
||||
# caught — a merchant can import explicit positions like 10/20.
|
||||
variants.append(CatalogVariant(
|
||||
id=pid * 100 + vi, options=(size, "Indigo", None), position=vi * 10,
|
||||
fields={"sku": f"SKU-{pid}-{vi}", "variant_image": None}))
|
||||
else:
|
||||
variants.append(CatalogVariant(
|
||||
id=pid * 100 + 1, options=(None, None, None), position=rng.choice([1, 5, 9]),
|
||||
fields={"sku": f"SKU-{pid}", "variant_image": None}))
|
||||
images = []
|
||||
for ii in range(rng.randint(0, 3)):
|
||||
images.append(CatalogImage(
|
||||
id=pid * 10 + ii, source_url=f"https://img/{handle}-{ii}.jpg",
|
||||
position=ii + 1, alt_text=rng.choice([None, f"alt {ii}"])))
|
||||
catalog[handle] = CatalogProduct(
|
||||
id=pid, handle=handle, title=fields["title"], option_names=option_names,
|
||||
fields=fields, variants=variants, images=images)
|
||||
return catalog
|
||||
|
||||
|
||||
def _roundtrip_diff(catalog: dict) -> diff.DiffResult:
|
||||
"""export → bytes → import pipeline → diff against the same catalog."""
|
||||
text = "".join(serialize.catalog_to_csv(catalog.values()))
|
||||
parsed = codec.parse_csv(text.encode("utf-8"))
|
||||
products = validate.build_products(parsed)
|
||||
return diff.compute_diff(catalog, products)
|
||||
|
||||
|
||||
def test_inv12_roundtrip_is_noop_over_generated_catalogs():
|
||||
for seed in range(200):
|
||||
catalog = _gen_catalog(seed)
|
||||
result = _roundtrip_diff(catalog)
|
||||
assert result.summary["adds"] == 0, f"seed {seed}: {result.summary}"
|
||||
assert result.summary["updates"] == 0, f"seed {seed}: {result.summary}"
|
||||
assert result.summary["errors"] == 0, f"seed {seed}: {result.summary}"
|
||||
assert result.summary["unchanged"] == len(catalog), f"seed {seed}"
|
||||
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def test_decimal_and_int_variant_fields_roundtrip():
|
||||
catalog = {
|
||||
"priced": CatalogProduct(
|
||||
id=1, handle="priced", title="Priced",
|
||||
option_names=(None, None, None),
|
||||
fields={"title": "Priced", "description_html": None, "vendor": None,
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": [], "status": "active", "published": True},
|
||||
variants=[CatalogVariant(
|
||||
id=1, options=(None, None, None), position=1,
|
||||
fields={"sku": "P1", "price": Decimal("18.00"),
|
||||
"cost": Decimal("9.5"), "weight": Decimal("0.250"),
|
||||
"inventory_qty": 40, "variant_image": None})],
|
||||
images=[],
|
||||
)
|
||||
}
|
||||
result = _roundtrip_diff(catalog)
|
||||
assert result.summary["unchanged"] == 1, result.records
|
||||
assert result.summary["updates"] == 0
|
||||
|
||||
|
||||
def test_nonsequential_variant_position_roundtrips():
|
||||
"""A stored variant position that isn't its file order must survive export —
|
||||
else re-import reads the empty cell as 'reset to file order' and the round-trip
|
||||
spuriously updates (INV-12 regression: position is a CatalogVariant attribute,
|
||||
not a fields{} entry, so the serializer must emit it explicitly)."""
|
||||
catalog = {
|
||||
"tee": CatalogProduct(
|
||||
id=1, handle="tee", title="Tee", option_names=("Size", None, None),
|
||||
fields={"title": "Tee", "description_html": None, "vendor": None,
|
||||
"product_type": "standalone", "google_product_category": None,
|
||||
"tags": [], "status": "active", "published": True},
|
||||
variants=[
|
||||
CatalogVariant(id=1, options=("S", None, None), position=10,
|
||||
fields={"sku": "T-S", "variant_image": None}),
|
||||
CatalogVariant(id=2, options=("M", None, None), position=20,
|
||||
fields={"sku": "T-M", "variant_image": None}),
|
||||
],
|
||||
images=[],
|
||||
)
|
||||
}
|
||||
result = _roundtrip_diff(catalog)
|
||||
assert result.summary["unchanged"] == 1, result.records
|
||||
assert result.summary["updates"] == 0
|
||||
@@ -0,0 +1,222 @@
|
||||
"""products service — drafts: validate/preview/discard (PUC-2/3/3a/5a; INV-11)."""
|
||||
import json
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import products
|
||||
from app.platform import db
|
||||
|
||||
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\nstar-tee,Star Tee,Acme,24.00\n"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def merchant(migrated_conn):
|
||||
acct = migrated_conn.execute(
|
||||
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||
sf = migrated_conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||
migrated_conn.execute(
|
||||
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||
migrated_conn.commit()
|
||||
return {"account_id": acct, "storefront_id": sf}
|
||||
|
||||
|
||||
def test_import_validate_creates_draft_with_summary(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
assert draft["dialect"] == "canonical"
|
||||
assert draft["summary"] == {"adds": 2, "updates": 0, "unchanged": 0, "errors": 0}
|
||||
assert draft["expires_at"]
|
||||
|
||||
|
||||
def test_validate_writes_nothing_to_catalog_inv11(migrated_conn, merchant):
|
||||
products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
|
||||
assert migrated_conn.execute("SELECT count(*) FROM variant").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_file_rejection_leaves_no_draft(migrated_conn, merchant):
|
||||
with pytest.raises(products.FileRejected):
|
||||
products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "bad.csv",
|
||||
b"Vendor,Price\nAcme,1\n")
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_records_paging_and_kind_filter(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
recs = products.get_draft_records(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||
assert [r["handle"] for r in recs] == ["moon-mug", "star-tee"]
|
||||
adds = products.get_draft_records(
|
||||
migrated_conn, merchant["storefront_id"], draft["id"], kind="add", limit=1)
|
||||
assert len(adds) == 1 and adds[0]["kind"] == "add"
|
||||
|
||||
|
||||
def test_discard_deletes_no_trace_puc3a(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) # idempotent
|
||||
|
||||
|
||||
def test_draft_scoped_to_storefront_inv14(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
other_sf = migrated_conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES ('Other') RETURNING id").fetchone()[0]
|
||||
migrated_conn.commit()
|
||||
with pytest.raises(products.DraftNotFound):
|
||||
products.get_draft(migrated_conn, other_sf, draft["id"])
|
||||
|
||||
|
||||
def test_expired_draft_raises_and_lazily_deletes(migrated_conn, merchant):
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
migrated_conn.execute(
|
||||
"UPDATE import_draft SET expires_at = now() - interval '1 minute' WHERE id = %s",
|
||||
(draft["id"],))
|
||||
migrated_conn.commit()
|
||||
with pytest.raises(products.DraftExpired):
|
||||
products.get_draft(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
|
||||
|
||||
@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_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||
products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||
assert any(
|
||||
e["event"] == "import_draft_created" and e["adds"] == 2 and e["row_count"] == 2
|
||||
and "duration_ms" in e and e["unknown_columns_count"] == 0
|
||||
for e in events
|
||||
)
|
||||
|
||||
|
||||
UPDATE_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,21.00\nstar-tee,Star Tee,Acme,24.00\n"
|
||||
MIXED_CSV = b"Handle,Title,Variant Price\ngood-mug,Mug,10.00\nbad-tee,Tee,not-a-price\n"
|
||||
|
||||
|
||||
def _validate(conn, m, data=GOOD_CSV):
|
||||
return products.import_validate(conn, m["storefront_id"], m["account_id"], "cat.csv", data)
|
||||
|
||||
|
||||
def test_confirm_applies_adds_and_records_run(migrated_conn, merchant):
|
||||
draft = _validate(migrated_conn, merchant)
|
||||
run_id = products.confirm_draft(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2
|
||||
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||
assert run["products_added"] == 2 and run["status"] == "complete"
|
||||
assert run["by"] == "m@example.com"
|
||||
assert run["image_progress"] == {"done": 0, "total": 0} and run["image_outcomes"] == []
|
||||
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_confirm_update_changes_only_diffed_fields(migrated_conn, merchant):
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
|
||||
assert d2["summary"] == {"adds": 0, "updates": 1, "unchanged": 1, "errors": 0}
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
price = migrated_conn.execute(
|
||||
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
|
||||
).fetchone()[0]
|
||||
assert str(price) == "21.00"
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 # no dupes (BUC-3)
|
||||
|
||||
|
||||
def test_confirm_blank_position_cell_round_trips(migrated_conn, merchant):
|
||||
# A present-but-empty Variant Position cell resolves to file order at diff
|
||||
# time — never SET position = NULL (which would abort the confirm on the
|
||||
# NOT NULL constraint).
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
blank_position_csv = (
|
||||
b"Handle,Title,Vendor,Variant Price,Variant Position\n"
|
||||
b"moon-mug,Moon Mug,Acme,21.00,\n"
|
||||
b"star-tee,Star Tee,Acme,24.00,\n"
|
||||
)
|
||||
d2 = _validate(migrated_conn, merchant, blank_position_csv)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
price = migrated_conn.execute(
|
||||
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
|
||||
).fetchone()[0]
|
||||
assert str(price) == "21.00"
|
||||
|
||||
|
||||
def test_confirm_mixed_applies_valid_records_errors(migrated_conn, merchant):
|
||||
draft = _validate(migrated_conn, merchant, MIXED_CSV)
|
||||
run_id = products.confirm_draft(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 1
|
||||
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||
assert run["rows_errored"] == 1
|
||||
assert run["errors"][0]["column"] == "Variant Price"
|
||||
|
||||
|
||||
def test_confirm_stale_fingerprint_409_inv11(migrated_conn, merchant):
|
||||
draft = _validate(migrated_conn, merchant)
|
||||
other = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], other["id"])
|
||||
with pytest.raises(products.PreviewStale):
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
|
||||
|
||||
def test_confirm_nothing_to_apply_puc10(migrated_conn, merchant):
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
d2 = _validate(migrated_conn, merchant)
|
||||
assert d2["summary"]["unchanged"] == 2
|
||||
with pytest.raises(products.NothingToApply):
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
|
||||
|
||||
def test_runs_history_newest_first(migrated_conn, merchant):
|
||||
d1 = _validate(migrated_conn, merchant)
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
|
||||
r2 = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||
runs = products.list_runs(migrated_conn, merchant["storefront_id"])
|
||||
assert [r["id"] for r in runs][0] == r2
|
||||
|
||||
|
||||
def test_summary_counts(migrated_conn, merchant):
|
||||
assert products.summary(migrated_conn, merchant["storefront_id"]) == {
|
||||
"product_count": 0, "image_problem_count": 0, "latest_run_id": None}
|
||||
d = _validate(migrated_conn, merchant)
|
||||
rid = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d["id"])
|
||||
s = products.summary(migrated_conn, merchant["storefront_id"])
|
||||
assert s == {"product_count": 2, "image_problem_count": 0, "latest_run_id": rid}
|
||||
|
||||
|
||||
def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||
draft = _validate(migrated_conn, merchant)
|
||||
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||
assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""§6.5.1 row validation — every row-error rule has a fixture (SD-0002 §6.8)."""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domains.products.codec import parse_csv
|
||||
from app.domains.products.validate import build_products
|
||||
|
||||
|
||||
def _products(*lines: str):
|
||||
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
||||
|
||||
|
||||
def _errors(*lines: str):
|
||||
return [e for p in _products(*lines) for e in p.errors]
|
||||
|
||||
|
||||
def test_simple_product_parses_clean():
|
||||
[p] = _products(
|
||||
"Handle,Title,Vendor,Tags,Status,Published,Variant Price,Variant SKU",
|
||||
"moon-mug,Moon Mug,Acme,\"kitchen, mugs\",active,TRUE,18.00,SKU-1",
|
||||
)
|
||||
assert p.valid and p.handle == "moon-mug" and p.title == "Moon Mug"
|
||||
assert p.fields["tags"] == ["kitchen", "mugs"]
|
||||
assert p.fields["status"] == "active" and p.fields["published"] is True
|
||||
[v] = p.variants
|
||||
assert v.options == (None, None, None)
|
||||
assert v.fields["price"] == Decimal("18.00") and v.fields["sku"] == "SKU-1"
|
||||
|
||||
|
||||
def test_option_product_groups_consecutive_rows():
|
||||
[p] = _products(
|
||||
"Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
||||
"tee,Tee,Size,S,24.00",
|
||||
"tee,,,M,24.00",
|
||||
"tee,,,L,26.00",
|
||||
)
|
||||
assert p.valid and p.option_names == ("Size", None, None)
|
||||
assert [v.options[0] for v in p.variants] == ["S", "M", "L"]
|
||||
|
||||
|
||||
def test_image_only_rows_and_dedupe():
|
||||
[p] = _products(
|
||||
"Handle,Title,Image Src,Image Position,Image Alt Text",
|
||||
"mug,Mug,https://x/a.jpg,1,front",
|
||||
"mug,,https://x/b.jpg,2,back",
|
||||
"mug,,https://x/a.jpg,3,dupe",
|
||||
)
|
||||
assert p.valid
|
||||
assert [(i.source_url, i.position) for i in p.images] == [
|
||||
("https://x/a.jpg", 1), ("https://x/b.jpg", 2),
|
||||
]
|
||||
|
||||
|
||||
def test_variant_image_joins_product_images():
|
||||
[p] = _products(
|
||||
"Handle,Title,Variant Image",
|
||||
"mug,Mug,https://x/v.jpg",
|
||||
)
|
||||
assert p.variants[0].fields["variant_image"] == "https://x/v.jpg"
|
||||
assert [i.source_url for i in p.images] == ["https://x/v.jpg"]
|
||||
|
||||
|
||||
def test_description_sanitized_inv15():
|
||||
[p] = _products(
|
||||
"Handle,Title,Description",
|
||||
'mug,Mug,"<p onclick=\'x()\'>hi</p><script>evil()</script>"',
|
||||
)
|
||||
html = p.fields["description_html"]
|
||||
assert "<p>" in html and "script" not in html and "onclick" not in html
|
||||
|
||||
|
||||
def test_blank_cell_clears_absent_column_missing():
|
||||
[p] = _products("Handle,Title,Vendor", "mug,Mug,")
|
||||
assert p.fields["vendor"] is None # present-but-empty == clear
|
||||
assert "status" not in p.fields # absent column == untouched
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"header,row,column,fragment",
|
||||
[
|
||||
("Handle,Title", ",NoHandle", "Handle", "needs a Handle"),
|
||||
("Handle,Title", "Bad_Handle!,T", "Handle", "isn't a valid handle"),
|
||||
("Handle,Title", "mug,", "Title", "missing its Title"),
|
||||
("Handle,Title,Type", "mug,Mug,kit_virtual", "Type", "kits arrive"),
|
||||
("Handle,Title,Component 1 SKU", "mug,Mug,ABC", "Component 1 SKU", "kits arrive"),
|
||||
("Handle,Title,Status", "mug,Mug,live", "Status", "is not a status"),
|
||||
("Handle,Title,Published", "mug,Mug,YES", "Published", "is not TRUE or FALSE"),
|
||||
("Handle,Title,Variant Price", 'mug,Mug,"12,50"', "Variant Price", "is not a price"),
|
||||
("Handle,Title,Variant Cost", "mug,Mug,-3", "Variant Cost", "is not a price"),
|
||||
("Handle,Title,Variant Weight", "mug,Mug,heavy", "Variant Weight", "is not a number"),
|
||||
("Handle,Title,Variant Inventory Qty", "mug,Mug,3.5", "Variant Inventory Qty", "is not a whole number"),
|
||||
("Handle,Title,Variant Position", "mug,Mug,0", "Variant Position", "is not a position"),
|
||||
("Handle,Title,Option1 Value", "mug,Mug,Red", "Option1 Value", "no Option1 Name"),
|
||||
],
|
||||
)
|
||||
def test_row_error_rules(header, row, column, fragment):
|
||||
errors = _errors(header, row)
|
||||
assert any(e.column == column and fragment in e.message for e in errors), errors
|
||||
|
||||
|
||||
def test_missing_option_value_for_named_option():
|
||||
errors = _errors(
|
||||
"Handle,Title,Option1 Name,Option1 Value,Variant SKU",
|
||||
"tee,Tee,Size,S,A",
|
||||
"tee,,,,B",
|
||||
)
|
||||
assert any("missing its Option1 Value" in e.message and e.line_number == 3 for e in errors)
|
||||
|
||||
|
||||
def test_duplicate_option_combo_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title,Option1 Name,Option1 Value",
|
||||
"tee,Tee,Size,S",
|
||||
"tee,,,S",
|
||||
)
|
||||
assert any("duplicate variant" in e.message for e in errors)
|
||||
|
||||
|
||||
def test_second_variant_on_no_option_product_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title,Variant SKU",
|
||||
"mug,Mug,A",
|
||||
"mug,,B",
|
||||
)
|
||||
assert any("without options can have only one variant" in e.message for e in errors)
|
||||
|
||||
|
||||
def test_non_consecutive_handle_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title",
|
||||
"mug,Mug",
|
||||
"tee,Tee",
|
||||
"mug,",
|
||||
)
|
||||
assert any("must be consecutive" in e.message and e.line_number == 4 for e in errors)
|
||||
|
||||
|
||||
def test_no_data_row_is_error():
|
||||
errors = _errors(
|
||||
"Handle,Title,Variant SKU,Image Src",
|
||||
"mug,Mug,A,",
|
||||
"mug,,,",
|
||||
)
|
||||
assert any("no variant or image data" in e.message for e in errors)
|
||||
|
||||
|
||||
def test_errors_poison_their_product_only():
|
||||
products = _products(
|
||||
"Handle,Title,Variant Price",
|
||||
"good-mug,Mug,10.00",
|
||||
"bad-tee,Tee,not-a-price",
|
||||
)
|
||||
by_handle = {p.handle: p for p in products}
|
||||
assert by_handle["good-mug"].valid
|
||||
assert not by_handle["bad-tee"].valid
|
||||
|
||||
|
||||
def test_all_errors_collected_not_first_only():
|
||||
[p] = _products(
|
||||
"Handle,Title,Status,Variant Price",
|
||||
"mug,,bogus,abc",
|
||||
)
|
||||
assert len(p.errors) == 3 # missing Title + bad status + bad price
|
||||
@@ -0,0 +1,125 @@
|
||||
# Operating ecomm
|
||||
|
||||
The framework-repo operator guide (SD-0002 DOC-1), started at SLICE-5. It covers
|
||||
the app's operational surface — telemetry, runbooks, alert gestures, the E2E
|
||||
gate. Per-deployment mechanics (deploy, secrets, VM access) live in the
|
||||
deployment's flotilla docs and `deployment.toml`; environment bring-up is in
|
||||
[`BOOTSTRAP.md`](./BOOTSTRAP.md).
|
||||
|
||||
## Products import/export ops (SD-0002, SLICE-5)
|
||||
|
||||
The §6.4 surface: a merchant uploads a catalog CSV (`POST
|
||||
/api/products/imports`), the app validates it and stores an **import draft**
|
||||
with a full preview (adds / updates / unchanged / errors), the merchant
|
||||
confirms or cancels at the preview gate, and a confirm applies the previewed
|
||||
diff as one **import run** recorded in the history (`/api/products/imports/runs`).
|
||||
|
||||
Caps and behavior to know (all enforced in code, not config):
|
||||
|
||||
- **Caps (INV-18):** ≤ 5,000 data rows and ≤ 10 MB per file —
|
||||
`MAX_DATA_ROWS` / `MAX_FILE_BYTES` in `backend/app/domains/products/models.py`,
|
||||
enforced in `backend/app/domains/products/codec.py` (file-level rejection)
|
||||
plus a 413 `file_too_large` guard in the BFF (`backend/app/main.py`).
|
||||
- **Draft expiry:** ~1 hour (`expires_at = now() + interval '1 hour'`). Cleanup
|
||||
is a lazy sweep — expired drafts are deleted on the next upload and on any
|
||||
access to an expired draft; there is no background job to babysit.
|
||||
- **Upsert-only (INV-10):** an import adds and updates, never deletes. Catalog
|
||||
products/variants/images absent from the file are untouched.
|
||||
- **One-transaction apply (INV-11):** a confirm applies the whole previewed
|
||||
diff in a single DB transaction — it lands completely or not at all.
|
||||
|
||||
### Telemetry
|
||||
|
||||
Structured JSON events on the `ecomm.telemetry` logger
|
||||
(`backend/app/platform/telemetry.py`), one JSON object per line, emitted from
|
||||
`backend/app/domains/products/service.py`. The app's `ecomm.*` log handler
|
||||
writes to the process's stderr, which journald captures on the VM (and Cloud
|
||||
Logging where the agent ships it). Events carry counts and durations only —
|
||||
never file names, URLs, catalog content, or secret bytes.
|
||||
|
||||
| Event | Trigger | Payload fields |
|
||||
| --- | --- | --- |
|
||||
| TEL-1 `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` |
|
||||
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
|
||||
| TEL-3 `catalog_exported` | export stream completes | `storefront_id, status_filter, product_count, duration_ms` |
|
||||
| TEL-6 `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` |
|
||||
|
||||
### Export (PUC-9, SLICE-6)
|
||||
|
||||
`GET /api/products/export?status=all|active|draft|archived` streams the
|
||||
storefront's catalog as a canonical-format CSV (one codec, two directions — the
|
||||
same format the importer parses). It is **read-only** (no draft, no run) and
|
||||
storefront-scoped (INV-14). An empty catalog — no products, or none matching the
|
||||
status filter — returns `409 empty_catalog`; the Products page disables the
|
||||
Export action with a note in that case. The round-trip is lossless (INV-12):
|
||||
re-importing an unmodified export previews as all-unchanged with the import
|
||||
action disabled (PUC-10). TEL-3 (`catalog_exported`) is emitted once the stream
|
||||
completes — counts and duration only, never catalog content.
|
||||
|
||||
### RB-2 — import apply failed
|
||||
|
||||
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
|
||||
rolled back.
|
||||
|
||||
1. **Locate the failure.** On the VM, filter the journal for the event and note
|
||||
the `draft_id`, `storefront_id`, and `error_class`:
|
||||
|
||||
```
|
||||
journalctl -u ecomm.service | grep import_apply_failed
|
||||
```
|
||||
|
||||
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
|
||||
failure leaves the catalog exactly as it was: the storefront's runs history
|
||||
(`GET /api/products/imports/runs`) shows **no new run**, and the products
|
||||
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
|
||||
3. **The merchant's draft is intact.** A failed apply does not consume the
|
||||
draft — the merchant can retry confirm, or re-upload if the draft has since
|
||||
expired (~1 h). Advise accordingly.
|
||||
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
|
||||
and the surrounding log context (the traceback is in the app log next to
|
||||
the event).
|
||||
|
||||
### ALR-2 — the log-based alert (one gesture per environment)
|
||||
|
||||
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
|
||||
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
|
||||
|
||||
```
|
||||
# Select the deployment's gcloud config for this one process (handbook §8.4).
|
||||
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
|
||||
|
||||
# Log-based metric counting import-apply failures (TEL-6).
|
||||
gcloud logging metrics create ecomm_import_apply_failed \
|
||||
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
|
||||
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
|
||||
```
|
||||
|
||||
Then attach an alert policy to the metric — **operator email channel, threshold
|
||||
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
|
||||
policy is created in the Cloud Console or with
|
||||
`gcloud alpha monitoring policies create`; the exact command depends on the
|
||||
notification-channel id, so list channels first:
|
||||
|
||||
```
|
||||
# Find the operator email channel's id for the policy.
|
||||
gcloud beta monitoring channels list
|
||||
```
|
||||
|
||||
### E2E browser suite
|
||||
|
||||
- Lives at `e2e/` — Playwright, Chromium, six scenarios (SLICE-5:
|
||||
preview/confirm happy path, actionable errors, file rejection, cancel;
|
||||
SLICE-6: `e2e_export_download`, `e2e_roundtrip_noop`).
|
||||
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
|
||||
database** against the local compose Postgres and serves the built SPA from
|
||||
the backend on **:8765** (the deployed topology), so it needs the dev
|
||||
Postgres up (`scripts/dev.sh`).
|
||||
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
|
||||
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
|
||||
the §9 pipeline (the PPE browser run is still manual this slice).
|
||||
|
||||
## Cross-references
|
||||
|
||||
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
|
||||
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
|
||||
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
|
||||
@@ -0,0 +1,147 @@
|
||||
# products domain — developer notes
|
||||
|
||||
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
|
||||
SLICE-6–8. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
|
||||
the spec is SD-0002 in the content repo.
|
||||
|
||||
## Layout and pipeline
|
||||
|
||||
`backend/app/domains/products/` is layered like the rest of the app
|
||||
(main → domains → platform, enforced by import-linter):
|
||||
|
||||
- `models.py` — canonical row model + the column registry.
|
||||
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
|
||||
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
|
||||
- `diff.py` — catalog × canonical products → apply plan + preview records.
|
||||
- `serialize.py` — the export half: `CatalogProduct` snapshot → canonical CSV
|
||||
(the inverse of `codec`/`validate`). DB-free, like `diff.py`.
|
||||
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
|
||||
Never commits or rolls back.
|
||||
- `service.py` — use-case orchestration; owns every transaction boundary and
|
||||
emits the TEL events via `backend/app/platform/telemetry.py`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph validate_path [import_validate]
|
||||
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
|
||||
C[load_catalog] --> D
|
||||
end
|
||||
subgraph confirm_path [confirm_draft]
|
||||
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
|
||||
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
|
||||
G -- no --> I[PreviewStale<br/>draft kept]
|
||||
end
|
||||
```
|
||||
|
||||
Confirm re-derives everything from the draft's stored `file_bytes` against the
|
||||
live catalog, then checks the fingerprint — so what lands is exactly what the
|
||||
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
|
||||
adds and no updates refuses with `nothing_to_apply`.
|
||||
|
||||
## Canonical model and blank-vs-absent
|
||||
|
||||
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
|
||||
is the registry header detection, unknown-column warnings, and validation all
|
||||
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
|
||||
(`status`, `published`, `product_type`, `tags`).
|
||||
|
||||
The §6.5.1 cell semantics, as implemented:
|
||||
|
||||
- **Absent column** → the field never enters `fields{}` → untouched by diff
|
||||
and apply (never a change).
|
||||
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
|
||||
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
|
||||
exists.
|
||||
- **The position exception:** a cleared `Variant Position` has no
|
||||
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
|
||||
variant's 1-based file order within its product, never to NULL.
|
||||
|
||||
Option names live both in `CanonicalProduct.option_names` (the values) and in
|
||||
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
|
||||
distinction).
|
||||
|
||||
## Error granularity
|
||||
|
||||
`validate.py` never raises on a row problem: every violation is recorded as a
|
||||
merchant-language `RowError` and **poisons its whole product block** — the
|
||||
product previews as `kind="error"` and is excluded from apply, while parsing
|
||||
continues so one pass yields a complete accounting (BUC-1a). On apply, error
|
||||
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
|
||||
the **error-row count**, while the preview's errors tile counts error
|
||||
**products** — the two numbers legitimately differ.
|
||||
|
||||
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
|
||||
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
|
||||
created. The BFF adds an early 413 for oversized uploads (`main.py`).
|
||||
|
||||
## INV-11 mechanics
|
||||
|
||||
`compute_diff` makes **one walk** that produces two views of the same
|
||||
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
|
||||
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
|
||||
stored as draft JSONB and served verbatim to the SPA. Because both derive from
|
||||
the same walk they cannot diverge. The `fingerprint` is
|
||||
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
|
||||
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
|
||||
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
|
||||
product/variant/image writes, error rows, draft delete — is one transaction;
|
||||
any exception rolls it back and emits TEL-6.
|
||||
|
||||
## Export & the round-trip (SLICE-6)
|
||||
|
||||
`serialize.py` is the export half of "one codec, two directions": it turns the
|
||||
`CatalogProduct` snapshot (the same one `repo.load_catalog` builds for the diff
|
||||
engine) back into canonical CSV, writing exactly the columns `codec.py` /
|
||||
`validate.py` parse, in the §6.5.1 row grammar — `HEADER` is the full canonical
|
||||
column set; product fields + option names sit on the first row; one variant per
|
||||
row; images interleave; a product with more images than variants emits
|
||||
image-only rows.
|
||||
|
||||
`repo.export_catalog` returns the status-filtered snapshot list (sorted by
|
||||
handle, deterministic); the `service.export_catalog` generator streams
|
||||
`serialize.catalog_to_csv` over it and emits TEL-3 (`catalog_exported`) once the
|
||||
stream is exhausted. The BFF wraps it in a `StreamingResponse`; an empty
|
||||
(filtered) catalog raises `EmptyCatalog` **eagerly** → `409 empty_catalog`
|
||||
before any bytes stream.
|
||||
|
||||
**INV-12** (`diff(catalog, import(export(catalog))) = ∅`) is locked two ways: a
|
||||
property test (`test_products_serialize.py`) runs the real
|
||||
export→parse→diff loop over 200 generated **text-field** catalogs and asserts
|
||||
every product is `unchanged`; the `e2e_roundtrip_noop` browser scenario does the
|
||||
same through the UI (export download → re-upload → all-unchanged preview, import
|
||||
disabled). Numeric/`Decimal` fields — the property test's deliberate blind spot
|
||||
(string-form vs value-identity) — get explicit round-trip unit tests.
|
||||
|
||||
## Named seams (what later slices replace)
|
||||
|
||||
- **`import_draft.file_bytes` → objectstore key (SLICE-7).** The upload
|
||||
currently lives as BYTEA on the draft row (`0002_products.sql`); SLICE-7
|
||||
moves the bytes to object storage and stores a key.
|
||||
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
|
||||
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
|
||||
- **Run-status complete shortcut → `fetching_images` (SLICE-7).**
|
||||
`confirm_draft` inserts the run with `status="complete"` directly; SLICE-7
|
||||
inserts it as `fetching_images` and hands off to the image-fetch task.
|
||||
Relatedly, image rows are created with the schema default
|
||||
`status='pending'` **today and stay pending** — placeholder behavior until
|
||||
SLICE-7's fetch phase (the run-detail payload already carries the stable
|
||||
`image_progress` / `image_outcomes` shape, zeroed).
|
||||
|
||||
## Test map
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| `backend/tests/test_products_codec.py` | file-level gates: parse, caps (INV-18), required columns, dialect |
|
||||
| `backend/tests/test_products_validate.py` | every §6.5.1 row-error rule, one fixture each |
|
||||
| `backend/tests/test_products_diff.py` | classification, blank-vs-absent, option matching, fingerprint |
|
||||
| `backend/tests/test_products_serialize.py` | serializer grammar + INV-12 property test (round-trip no-op over generated catalogs) + decimal round-trip |
|
||||
| `backend/tests/test_products_export.py` | status-filtered snapshot, streamed export, EmptyCatalog, TEL-3 |
|
||||
| `backend/tests/test_products_service.py` | draft lifecycle: validate/preview/discard, expiry, TEL-1 |
|
||||
| `backend/tests/test_products_invariants.py` | INV-10 (never deletes), INV-14 (storefront isolation), apply transactionality, TEL-6 |
|
||||
| `backend/tests/test_products_endpoints.py` | §6.4 API scenarios + auth/storefront gates |
|
||||
| `e2e/tests/import-preview-confirm.spec.ts` | happy path: upload → preview → confirm → history |
|
||||
| `e2e/tests/import-errors.spec.ts` | actionable row errors at preview and on the run report |
|
||||
| `e2e/tests/import-file-rejected.spec.ts` | file-level rejection, picker stays live, no trace |
|
||||
| `e2e/tests/import-cancel.spec.ts` | cancel at preview leaves no trace |
|
||||
| `e2e/tests/export-download.spec.ts` | export downloads canonical CSV, status filter respected |
|
||||
| `e2e/tests/roundtrip-noop.spec.ts` | export → re-import → all-unchanged, import disabled (PUC-10) |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.backend.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,4 @@
|
||||
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
|
||||
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
|
||||
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
|
||||
star-tee,,,,,,,M,WG-TEE-M,24.00,18
|
||||
|
@@ -0,0 +1,2 @@
|
||||
Handle,Vendor
|
||||
mug,Acme
|
||||
|
@@ -0,0 +1,4 @@
|
||||
Handle,Title,Variant Price
|
||||
good-mug,Good Mug,10.00
|
||||
bad-tee,Bad Tee,not-a-price
|
||||
also-good,Also Good,5.00
|
||||
|
+110
@@ -0,0 +1,110 @@
|
||||
// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page
|
||||
// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens
|
||||
// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx).
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const LOG = join(__dirname, ".backend.log");
|
||||
let seq = 0;
|
||||
|
||||
// The storefront name every helper-created merchant uses; tests assert against it.
|
||||
export const STOREFRONT_NAME = "E2E Test Goods";
|
||||
|
||||
export function freshEmail(): string {
|
||||
return `merchant${Date.now()}-${seq++}@example.com`;
|
||||
}
|
||||
|
||||
// LogMailer's exact line (backend/app/platform/mailer.py):
|
||||
// INFO:ecomm.mailer: LogMailer -> <to> | Your ecomm code: <6 digits>\n<body>
|
||||
// The first 6-digit group after the marker is the code.
|
||||
async function codeFor(email: string): Promise<string> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const log = await readFile(LOG, "utf8").catch(() => "");
|
||||
const at = log.lastIndexOf(`LogMailer -> ${email}`);
|
||||
if (at >= 0) {
|
||||
const m = log.slice(at).match(/\b(\d{6})\b/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error(`no code logged for ${email}`);
|
||||
}
|
||||
|
||||
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
|
||||
// Landing → the sign-up door.
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create your storefront →" }).click();
|
||||
|
||||
// Sign-in step 1: request the one-time code.
|
||||
await page.getByLabel("Email").fill(email);
|
||||
await page.getByRole("button", { name: "Send code" }).click();
|
||||
|
||||
// Sign-in step 2: read the code from the backend log and verify it.
|
||||
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
|
||||
const code = await codeFor(email);
|
||||
await page.getByLabel("One-time code").fill(code);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// Create-storefront screen (a new account has none yet).
|
||||
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
|
||||
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
|
||||
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
|
||||
|
||||
// Admin shell: nav strip + the storefront identity in the topbar.
|
||||
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
|
||||
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||
return email;
|
||||
}
|
||||
|
||||
export async function gotoProducts(page: Page) {
|
||||
await page
|
||||
.getByRole("navigation", { name: "Admin sections" })
|
||||
.getByRole("link", { name: "Products" })
|
||||
.click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function uploadFixture(page: Page, fixture: string) {
|
||||
// Two "Import products" links render on the empty products page (header + empty
|
||||
// state) — same destination, so take the first.
|
||||
await page.getByRole("link", { name: "Import products" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
|
||||
}
|
||||
|
||||
// Import good.csv and confirm it, leaving a 2-product catalog. Returns nothing;
|
||||
// callers continue from the run-detail screen.
|
||||
export async function importGoodCsv(page: Page) {
|
||||
await uploadFixture(page, "good.csv");
|
||||
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
||||
}
|
||||
|
||||
// Click an Export status option and capture the downloaded CSV's text + path.
|
||||
export async function exportCatalog(page: Page, label: string): Promise<{ text: string; path: string }> {
|
||||
// Open the <details> menu, then click the status option, capturing the download.
|
||||
// The menu is a native <details> that *toggles* on each summary click and is
|
||||
// NOT closed by clicking a download <a> (a download doesn't navigate). So on a
|
||||
// second export the menu may already be open — deterministically open it
|
||||
// rather than blind-toggling, and wait for the status link to be visible.
|
||||
const details = page.locator("details.products__export");
|
||||
if (!(await details.evaluate((el: HTMLDetailsElement) => el.open))) {
|
||||
await details.locator("> summary").click();
|
||||
}
|
||||
const link = page.getByRole("link", { name: label, exact: true });
|
||||
await expect(link).toBeVisible();
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent("download"),
|
||||
link.click(),
|
||||
]);
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of stream) chunks.push(c as Buffer);
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
const path = join(tmpdir(), `export-${Date.now()}.csv`);
|
||||
await writeFile(path, text, "utf8");
|
||||
return { text, path };
|
||||
}
|
||||
Generated
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"private": true,
|
||||
"scripts": { "test": "playwright test" },
|
||||
"devDependencies": { "@playwright/test": "^1.48.0" }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
// One shared backend + log file; parallel sign-ins would interleave codes.
|
||||
workers: 1,
|
||||
use: { baseURL: "http://localhost:8765" },
|
||||
webServer: {
|
||||
command: "bash ./serve.sh",
|
||||
url: "http://localhost:8765/healthz",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
|
||||
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
|
||||
set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
|
||||
( cd "$repo_root/frontend" && npm run build )
|
||||
fi
|
||||
|
||||
"$repo_root/.venv/bin/python" - <<'PY'
|
||||
import psycopg
|
||||
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
|
||||
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
|
||||
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
|
||||
admin.execute("CREATE DATABASE ecomm_e2e")
|
||||
PY
|
||||
|
||||
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
|
||||
export ECOMM_MAILER=log
|
||||
cd "$repo_root/backend"
|
||||
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
|
||||
> "$repo_root/e2e/.backend.log" 2>&1
|
||||
@@ -0,0 +1,21 @@
|
||||
// DoD scenario e2e_export_download (SD-0002 §6.8): export downloads a canonical
|
||||
// CSV and the status filter is respected.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
|
||||
|
||||
test("e2e_export_download", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await importGoodCsv(page);
|
||||
await gotoProducts(page);
|
||||
|
||||
// Export "All products" → a canonical CSV with both handles.
|
||||
const all = await exportCatalog(page, "All products");
|
||||
expect(all.text.split("\n")[0]).toContain("Handle,");
|
||||
expect(all.text).toContain("moon-mug");
|
||||
expect(all.text).toContain("star-tee");
|
||||
|
||||
// good.csv's products are active → "Active" exports both, "Draft" is empty/disabled-path.
|
||||
const active = await exportCatalog(page, "Active");
|
||||
expect(active.text).toContain("moon-mug");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the
|
||||
// preview gate (PUC-3a) leaves no trace — no products, no run in the history.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_cancel_no_trace", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await uploadFixture(page, "good.csv");
|
||||
|
||||
// The preview gate is up.
|
||||
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
// Lands back on Products — still the empty state, no run recorded.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are
|
||||
// actionable — line, column, message — at the preview gate AND on the run report
|
||||
// card (PUC-5), while the good rows still import.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_errors_actionable", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await uploadFixture(page, "mixed-errors.csv");
|
||||
|
||||
// Preview tiles: two good products, one errored row.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "1 errors" }).click();
|
||||
|
||||
// The error table names the line, the column, and the problem (actionable, PUC-5).
|
||||
const previewRow = page.locator(".errortable tbody tr");
|
||||
await expect(previewRow).toHaveCount(1);
|
||||
await expect(previewRow.locator("td").nth(0)).toHaveText("3");
|
||||
await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||
await expect(previewRow.locator("td").nth(2)).toContainText("is not a price");
|
||||
|
||||
// Good rows still import.
|
||||
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||
|
||||
// Run detail: counts include the errored row, and the same error row persists.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible();
|
||||
await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible();
|
||||
const runRow = page.locator(".errortable tbody tr");
|
||||
await expect(runRow).toHaveCount(1);
|
||||
await expect(runRow.locator("td").nth(0)).toHaveText("3");
|
||||
await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||
await expect(runRow.locator("td").nth(2)).toContainText("is not a price");
|
||||
|
||||
// The catalog gained the two good products.
|
||||
await gotoProducts(page);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection
|
||||
// (PUC-5a) renders in place on the upload screen, the picker stays live for a
|
||||
// retry, and nothing is recorded — no products, no run.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_file_rejected", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await uploadFixture(page, "missing-title.csv");
|
||||
|
||||
// The rejection renders in place, naming the missing column.
|
||||
await expect(page.getByText("That file can't be imported")).toBeVisible();
|
||||
await expect(page.getByText("missing the required column 'Title'")).toBeVisible();
|
||||
|
||||
// The picker is live again for a retry.
|
||||
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||||
|
||||
// No trace: still the empty catalog, and no run recorded.
|
||||
await gotoProducts(page);
|
||||
await expect(
|
||||
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to
|
||||
// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm,
|
||||
// run report card, and the catalog + history reflecting the import. Subsumes the
|
||||
// Task-15 harness smoke (sign-up journey + products empty state).
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_preview_confirm", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
|
||||
// Admin topbar: storefront identity + the signed-in account chip (ex-smoke).
|
||||
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
|
||||
|
||||
await gotoProducts(page);
|
||||
|
||||
// Empty state + the import affordances (SD-0002 §5.2, ex-smoke).
|
||||
await expect(
|
||||
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
|
||||
|
||||
await uploadFixture(page, "good.csv");
|
||||
|
||||
// Preview (§5.4): summary tiles + the file's name in the heading.
|
||||
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
|
||||
|
||||
// Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU).
|
||||
const starTee = page.locator(".difflist__item", { hasText: "star-tee" });
|
||||
await starTee.locator("summary").click();
|
||||
await expect(starTee.getByText("WG-TEE-S")).toBeVisible();
|
||||
|
||||
// Consent gate (PUC-3): confirm the import.
|
||||
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||
|
||||
// Run detail (§5.5): report card with the file name, counts, and status.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
||||
await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible();
|
||||
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
|
||||
|
||||
// Back on Products: the catalog count and exactly one history row for this run.
|
||||
await gotoProducts(page);
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||
const rows = page.locator(".datatable tbody tr");
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// DoD scenario e2e_roundtrip_noop (SD-0002 §6.8, PUC-10): exporting then
|
||||
// re-importing the unmodified file previews as all-unchanged with the import
|
||||
// action disabled and the "Nothing to change" note.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
|
||||
|
||||
test("e2e_roundtrip_noop", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await importGoodCsv(page);
|
||||
await gotoProducts(page);
|
||||
|
||||
// Export the catalog, then re-import the unmodified file.
|
||||
const { path } = await exportCatalog(page, "All products");
|
||||
await page.getByRole("link", { name: "Import products" }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
|
||||
await page.locator('input[type="file"]').setInputFiles(path);
|
||||
|
||||
// Preview: everything unchanged, nothing to add/update (PUC-10).
|
||||
await expect(page.getByRole("button", { name: "2 unchanged" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "0 to add" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "0 to update" })).toBeVisible();
|
||||
|
||||
// The import action is disabled, with the no-op note.
|
||||
const importBtn = page.getByRole("button", { name: /^Import 0 products$/ });
|
||||
await expect(importBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText("Nothing to change — your catalog already matches this file"),
|
||||
).toBeVisible();
|
||||
});
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminViewFor, hashFor, type AdminView } from "./adminRouting";
|
||||
|
||||
const VIEWS: AdminView[] = [
|
||||
{ view: "home" }, { view: "products" }, { view: "import-upload" },
|
||||
{ view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 },
|
||||
];
|
||||
|
||||
describe("adminRouting", () => {
|
||||
it("round-trips every view", () => {
|
||||
for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v);
|
||||
});
|
||||
it("defaults junk to home/products", () => {
|
||||
expect(adminViewFor("")).toEqual({ view: "home" });
|
||||
expect(adminViewFor("#/nonsense")).toEqual({ view: "home" });
|
||||
expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a
|
||||
// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing
|
||||
// rule (routing.ts) still decides whether the admin renders at all.
|
||||
export type AdminView =
|
||||
| { view: "home" }
|
||||
| { view: "products" }
|
||||
| { view: "import-upload" }
|
||||
| { view: "import-preview"; draftId: number }
|
||||
| { view: "run-detail"; runId: number };
|
||||
|
||||
export function adminViewFor(hash: string): AdminView {
|
||||
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
|
||||
if (parts[0] !== "products") return { view: "home" };
|
||||
if (parts.length === 1) return { view: "products" };
|
||||
if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" };
|
||||
if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? ""))
|
||||
return { view: "import-preview", draftId: Number(parts[3]) };
|
||||
if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? ""))
|
||||
return { view: "run-detail", runId: Number(parts[3]) };
|
||||
return { view: "products" };
|
||||
}
|
||||
|
||||
export function hashFor(v: AdminView): string {
|
||||
switch (v.view) {
|
||||
case "home": return "#/";
|
||||
case "products": return "#/products";
|
||||
case "import-upload": return "#/products/import";
|
||||
case "import-preview": return `#/products/imports/drafts/${v.draftId}`;
|
||||
case "run-detail": return `#/products/imports/runs/${v.runId}`;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ export interface VerifyResult {
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
async function errorOf(resp: Response): Promise<ApiError> {
|
||||
export async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXPORT_STATUSES, exportUrl } from "./productsApi";
|
||||
|
||||
describe("export url", () => {
|
||||
it("lists the four status filters with 'all' first", () => {
|
||||
expect(EXPORT_STATUSES.map((s) => s.value)).toEqual(["all", "active", "draft", "archived"]);
|
||||
});
|
||||
it("builds the endpoint url with the status query", () => {
|
||||
expect(exportUrl("all")).toBe("/api/products/export?status=all");
|
||||
expect(exportUrl("archived")).toBe("/api/products/export?status=archived");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same
|
||||
// conventions as api.ts: same-origin, cookie session, §6.4 error envelope.
|
||||
import { errorOf, type ApiError } from "./api";
|
||||
|
||||
export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; }
|
||||
export interface Draft {
|
||||
id: number; file_name: string; dialect: string;
|
||||
summary: DiffSummary; unknown_columns: string[]; expires_at: string;
|
||||
}
|
||||
export type RecordKind = "add" | "update" | "unchanged" | "error";
|
||||
export interface RowErrorDetail { line: number; column: string | null; message: string; }
|
||||
export interface FieldChange { field: string; before: unknown; after: unknown; }
|
||||
export interface VariantEntry {
|
||||
options: (string | null)[]; kind?: string;
|
||||
set?: Record<string, unknown>; changes?: FieldChange[];
|
||||
}
|
||||
export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; }
|
||||
export interface DraftRecord {
|
||||
handle: string; title: string; kind: RecordKind; variant_count: number;
|
||||
detail: {
|
||||
set?: Record<string, unknown>;
|
||||
option_names?: (string | null)[];
|
||||
changes?: FieldChange[];
|
||||
variants?: VariantEntry[];
|
||||
images?: ImageEntry[];
|
||||
errors?: RowErrorDetail[];
|
||||
};
|
||||
}
|
||||
export interface RunSummary {
|
||||
id: number; file_name: string; dialect: string; created_at: string;
|
||||
completed_at: string | null; status: string; by: string;
|
||||
products_added: number; products_updated: number; rows_errored: number;
|
||||
}
|
||||
export interface RunDetail extends RunSummary {
|
||||
errors: RowErrorDetail[];
|
||||
image_progress: { done: number; total: number };
|
||||
image_outcomes: unknown[];
|
||||
}
|
||||
export interface ProductsSummary {
|
||||
product_count: number; image_problem_count: number; latest_run_id: number | null;
|
||||
}
|
||||
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
|
||||
|
||||
// One label rule for CSV dialects, shared by Products history / preview / run detail.
|
||||
export function dialectLabel(d: string): string {
|
||||
return d === "canonical" ? "Canonical format" : d;
|
||||
}
|
||||
|
||||
export type ExportStatus = "all" | "active" | "draft" | "archived";
|
||||
|
||||
// PUC-9 status filter, 'all' first (the default). Labels drive the export menu.
|
||||
export const EXPORT_STATUSES: { value: ExportStatus; label: string }[] = [
|
||||
{ value: "all", label: "All products" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "draft", label: "Draft" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
];
|
||||
|
||||
// The export is a browser download (native save dialog + streaming), not a fetch
|
||||
// wrapper — so the API module contributes a URL builder, not a request().
|
||||
export function exportUrl(status: ExportStatus): string {
|
||||
return `/api/products/export?status=${status}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<Result<T>> {
|
||||
const resp = await fetch(path, { credentials: "include", ...init });
|
||||
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
|
||||
if (resp.status === 204) return { ok: true, value: undefined as T };
|
||||
return { ok: true, value: (await resp.json()) as T };
|
||||
}
|
||||
|
||||
export function getProductsSummary(): Promise<Result<ProductsSummary>> {
|
||||
return request("/api/products/summary");
|
||||
}
|
||||
|
||||
export function uploadImport(file: File): Promise<Result<Draft>> {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
// No content-type header: the browser sets the multipart boundary.
|
||||
return request("/api/products/imports", { method: "POST", body });
|
||||
}
|
||||
|
||||
export function getDraft(id: number): Promise<Result<Draft>> {
|
||||
return request(`/api/products/imports/drafts/${id}`);
|
||||
}
|
||||
|
||||
export async function getDraftRecords(
|
||||
id: number, kind?: RecordKind, limit = 100, offset = 0,
|
||||
): Promise<Result<DraftRecord[]>> {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (kind) params.set("kind", kind);
|
||||
const resp = await request<{ records: DraftRecord[] }>(
|
||||
`/api/products/imports/drafts/${id}/records?${params}`,
|
||||
);
|
||||
return resp.ok ? { ok: true, value: resp.value.records } : resp;
|
||||
}
|
||||
|
||||
export function confirmDraft(id: number): Promise<Result<{ run_id: number }>> {
|
||||
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function cancelDraft(id: number): Promise<Result<void>> {
|
||||
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export async function listRuns(): Promise<Result<RunSummary[]>> {
|
||||
const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs");
|
||||
return resp.ok ? { ok: true, value: resp.value.runs } : resp;
|
||||
}
|
||||
|
||||
export function getRun(id: number): Promise<Result<RunDetail>> {
|
||||
return request(`/api/products/imports/runs/${id}`);
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
// Admin shell (SD-0001 §5.4) — the storefront's stable home; honestly empty this release
|
||||
// (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + signed-in email. No
|
||||
// zeroed metric tiles, no locked-feature teasers (OHM: Agency & Anti-Manipulation).
|
||||
// Visuals per the ui/designs export (hf-admin).
|
||||
// Admin shell (SD-0001 §5.4) — the storefront's stable home; the home view is honestly
|
||||
// empty this release (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name +
|
||||
// signed-in email. No zeroed metric tiles, no locked-feature teasers (OHM: Agency &
|
||||
// Anti-Manipulation). Visuals per the ui/designs export (hf-admin). SD-0002 §5 adds the
|
||||
// admin nav strip + hash-routed products section (adminRouting.ts).
|
||||
import { useEffect, useState } from "react";
|
||||
import { adminViewFor, type AdminView } from "../adminRouting";
|
||||
import { logout } from "../api";
|
||||
import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit";
|
||||
import ImportPreview from "./products/ImportPreview";
|
||||
import ImportUpload from "./products/ImportUpload";
|
||||
import ProductsPage from "./products/ProductsPage";
|
||||
import RunDetail from "./products/RunDetail";
|
||||
|
||||
interface Props {
|
||||
storefrontName: string;
|
||||
@@ -13,6 +20,14 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) {
|
||||
const [view, setView] = useState<AdminView>(adminViewFor(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setView(adminViewFor(window.location.hash));
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
@@ -32,27 +47,41 @@ export default function Admin({ storefrontName, email, welcome, onSignedOut }: P
|
||||
}
|
||||
right={<AccountChip email={email} onSignOut={signOut} />}
|
||||
/>
|
||||
<nav className="adminnav" aria-label="Admin sections">
|
||||
<a className={`adminnav__item${view.view === "home" ? " adminnav__item--active" : ""}`} href="#/">
|
||||
Overview
|
||||
</a>
|
||||
<a className={`adminnav__item${view.view !== "home" ? " adminnav__item--active" : ""}`} href="#/products">
|
||||
Products
|
||||
</a>
|
||||
</nav>
|
||||
<main className="screen__main">
|
||||
<div className="empty">
|
||||
{welcome && (
|
||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
{view.view === "home" && (
|
||||
<div className="empty">
|
||||
{welcome && (
|
||||
<div style={{ marginBottom: 24, width: "100%" }}>
|
||||
<Banner tone="info" title={welcome === "new" ? "Welcome to ecomm" : "Welcome back"}>
|
||||
{welcome === "new"
|
||||
? "A new account was created for this email."
|
||||
: "Signed in to your existing account."}
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
<div className="empty__seal" aria-hidden="true">
|
||||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||||
</div>
|
||||
)}
|
||||
<div className="empty__seal" aria-hidden="true">
|
||||
<img src="/brand/mark-mono-gold.svg" width={36} height={36} alt="" />
|
||||
<Eyebrow>Your storefront</Eyebrow>
|
||||
<h1>{storefrontName}</h1>
|
||||
<p className="empty__copy">
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
<Eyebrow>Your storefront</Eyebrow>
|
||||
<h1>{storefrontName}</h1>
|
||||
<p className="empty__copy">
|
||||
There's nothing to manage yet — and that's a finished state, not a missing one.
|
||||
Catalog, orders, and settings will appear here as ecomm grows.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{view.view === "products" && <ProductsPage />}
|
||||
{view.view === "import-upload" && <ImportUpload />}
|
||||
{view.view === "import-preview" && <ImportPreview draftId={view.draftId} />}
|
||||
{view.view === "run-detail" && <RunDetail runId={view.runId} />}
|
||||
</main>
|
||||
</Screen>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
|
||||
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
|
||||
// Diff glyphs pair with color, never color alone (§6.6).
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
cancelDraft,
|
||||
confirmDraft,
|
||||
dialectLabel,
|
||||
getDraft,
|
||||
getDraftRecords,
|
||||
type Draft,
|
||||
type DraftRecord,
|
||||
type FieldChange,
|
||||
type RecordKind,
|
||||
type VariantEntry,
|
||||
} from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const PAGE = 100;
|
||||
|
||||
function fmt(v: unknown): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (Array.isArray(v)) return v.length ? v.join(", ") : "—";
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function KindChip({ kind }: { kind: RecordKind }) {
|
||||
return <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
|
||||
}
|
||||
|
||||
function SetLine({ field, value }: { field: string; value: unknown }) {
|
||||
return (
|
||||
<div className="diffchange">
|
||||
<span className="diffchange__glyph--add">+ </span>
|
||||
{field}: {fmt(value)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeRow({ change }: { change: FieldChange }) {
|
||||
return (
|
||||
<div className="diffchange">
|
||||
{change.field}: <span className="diffchange__glyph--del">− {fmt(change.before)}</span> →{" "}
|
||||
<span className="diffchange__glyph--add">+ {fmt(change.after)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function variantLabel(v: VariantEntry): string {
|
||||
const opts = v.options.filter((o): o is string => o != null);
|
||||
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
|
||||
}
|
||||
|
||||
function RecordDetail({ record }: { record: DraftRecord }) {
|
||||
const d = record.detail;
|
||||
if (record.kind === "error") {
|
||||
return (
|
||||
<div>
|
||||
{(d.errors ?? []).map((e, i) => (
|
||||
<div className="diffchange" key={i}>
|
||||
line {e.line}: {e.column != null && `'${e.column}' — `}
|
||||
{e.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(d.set ?? {}).map(([field, value]) => (
|
||||
<SetLine key={field} field={field} value={value} />
|
||||
))}
|
||||
{(d.changes ?? []).map((c, i) => (
|
||||
<ChangeRow key={i} change={c} />
|
||||
))}
|
||||
{(d.variants ?? []).map((v, i) => (
|
||||
<div key={i}>
|
||||
<div className="diffchange diffchange--head">
|
||||
{variantLabel(v)}
|
||||
{v.kind ? ` (${v.kind})` : ""}
|
||||
</div>
|
||||
{Object.entries(v.set ?? {}).map(([field, value]) => (
|
||||
<SetLine key={field} field={field} value={value} />
|
||||
))}
|
||||
{(v.changes ?? []).map((c, j) => (
|
||||
<ChangeRow key={j} change={c} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{(d.images ?? []).map((img, i) =>
|
||||
img.kind && img.kind !== "add" ? (
|
||||
<div key={i}>
|
||||
<div className="diffchange diffchange--head">
|
||||
image: {img.src} ({img.kind})
|
||||
</div>
|
||||
{(img.changes ?? []).map((c, j) => (
|
||||
<ChangeRow key={j} change={c} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="diffchange" key={i}>
|
||||
<span className="diffchange__glyph--add">+ </span>image: {img.src}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorTable({ records }: { records: DraftRecord[] }) {
|
||||
const rows = records.flatMap((r) => r.detail.errors ?? []);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Column</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.line}</td>
|
||||
<td>{e.column ?? "—"}</td>
|
||||
<td>{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportPreview({ draftId }: { draftId: number }) {
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
|
||||
const [filter, setFilter] = useState<RecordKind | null>(null);
|
||||
const [records, setRecords] = useState<DraftRecord[] | null>(null);
|
||||
const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [moreBusy, setMoreBusy] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [nothingNote, setNothingNote] = useState(false);
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||
// Generation counter for the records list: bumped on every page-0 (re)load, so a
|
||||
// page-0 or show-more response that resolves after a tile/filter (or draft) switch
|
||||
// is recognized as stale and dropped instead of clobbering/appending to the new list.
|
||||
const recordsGen = useRef(0);
|
||||
|
||||
async function loadDraft() {
|
||||
setLoadFail(null);
|
||||
const resp = await getDraft(draftId);
|
||||
if (!resp.ok) {
|
||||
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
|
||||
return;
|
||||
}
|
||||
setDraft(resp.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
// A new draft means a fresh consent gate — reset everything the old one set.
|
||||
setFilter(null);
|
||||
setStale(false);
|
||||
setConfirmError(null);
|
||||
setNothingNote(false);
|
||||
setRecords(null);
|
||||
setRecordsError(null);
|
||||
setHasMore(false);
|
||||
void loadDraft();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftId]);
|
||||
|
||||
function loadRecords(kind: RecordKind | null) {
|
||||
recordsGen.current += 1;
|
||||
const gen = recordsGen.current;
|
||||
setRecords(null);
|
||||
setRecordsError(null);
|
||||
setHasMore(false);
|
||||
void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => {
|
||||
if (gen !== recordsGen.current) return;
|
||||
if (!resp.ok) {
|
||||
setRecordsError("load");
|
||||
return;
|
||||
}
|
||||
setRecords(resp.value);
|
||||
setHasMore(resp.value.length === PAGE);
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
loadRecords(filter);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft, draftId, filter]);
|
||||
|
||||
async function showMore() {
|
||||
if (!records) return;
|
||||
const gen = recordsGen.current;
|
||||
setMoreBusy(true);
|
||||
setRecordsError(null);
|
||||
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
|
||||
setMoreBusy(false);
|
||||
// Filter/draft switched while this page was in flight — drop the stale page.
|
||||
if (gen !== recordsGen.current) return;
|
||||
if (!resp.ok) {
|
||||
setRecordsError("more");
|
||||
return;
|
||||
}
|
||||
setRecords((prev) => [...(prev ?? []), ...resp.value]);
|
||||
setHasMore(resp.value.length === PAGE);
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
setConfirming(true);
|
||||
setConfirmError(null);
|
||||
const resp = await confirmDraft(draftId);
|
||||
if (resp.ok) {
|
||||
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
|
||||
setStale(true);
|
||||
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
|
||||
setNothingNote(true);
|
||||
} else {
|
||||
setConfirmError(resp.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
setCancelling(true);
|
||||
// PUC-3a — cancel even on draft-gone (404) still navigates home.
|
||||
await cancelDraft(draftId);
|
||||
window.location.hash = "#/products";
|
||||
}
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="This preview is gone">
|
||||
<a href="#/products">Back to Products</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "expired") {
|
||||
return (
|
||||
<Banner tone="attn" title="This preview expired — upload the file again">
|
||||
<a href="#/products/import">Upload the file again</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "failed") {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load this preview">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void loadDraft()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!draft) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { summary } = draft;
|
||||
const tiles: { kind: RecordKind; num: number; label: string }[] = [
|
||||
{ kind: "add", num: summary.adds, label: "to add" },
|
||||
{ kind: "update", num: summary.updates, label: "to update" },
|
||||
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
|
||||
{ kind: "error", num: summary.errors, label: "errors" },
|
||||
];
|
||||
const toApply = summary.adds + summary.updates;
|
||||
|
||||
return (
|
||||
<div className="products">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>Import preview — {draft.file_name}</h1>
|
||||
<p className="note">{dialectLabel(draft.dialect)}</p>
|
||||
{draft.unknown_columns.length > 0 && (
|
||||
<Banner tone="info" title="Columns not imported">
|
||||
{draft.unknown_columns.length > 8 ? (
|
||||
<details>
|
||||
<summary>{draft.unknown_columns.length} columns not imported</summary>
|
||||
{draft.unknown_columns.join(", ")}
|
||||
</details>
|
||||
) : (
|
||||
draft.unknown_columns.join(", ")
|
||||
)}
|
||||
</Banner>
|
||||
)}
|
||||
<div className="tiles">
|
||||
{tiles.map((t) => (
|
||||
<button
|
||||
key={t.kind}
|
||||
type="button"
|
||||
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
|
||||
aria-pressed={filter === t.kind}
|
||||
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
|
||||
>
|
||||
<span className="tile__num">{t.num.toLocaleString()}</span>
|
||||
<span className="tile__label">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{filter === "error" && records && <ErrorTable records={records} />}
|
||||
{recordsError === "load" ? (
|
||||
<p className="note note--attn" role="alert">
|
||||
Couldn't load these records.{" "}
|
||||
<button type="button" className="linklike" onClick={() => loadRecords(filter)}>
|
||||
Retry
|
||||
</button>
|
||||
</p>
|
||||
) : records === null ? (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
) : records.length === 0 ? (
|
||||
<p className="note">Nothing to show here.</p>
|
||||
) : (
|
||||
<div className="difflist">
|
||||
{records.map((r, i) => (
|
||||
<details className="difflist__item" key={`${r.handle}-${i}`}>
|
||||
<summary>
|
||||
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
|
||||
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
|
||||
{r.variant_count === 1 ? "variant" : "variants"}
|
||||
</summary>
|
||||
<RecordDetail record={r} />
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<p>
|
||||
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
|
||||
{moreBusy ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
{recordsError === "more" && (
|
||||
<span className="note note--attn" role="alert">
|
||||
Couldn't load more records.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void showMore()}>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="sticky-footer" aria-live="polite">
|
||||
{stale ? (
|
||||
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
|
||||
<a href="#/products/import">Upload the file again</a>
|
||||
</Banner>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={toApply === 0 || confirming || cancelling}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={confirming || cancelling}
|
||||
onClick={() => void onCancel()}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{(toApply === 0 || nothingNote) && (
|
||||
<span className="note">Nothing to change — your catalog already matches this file</span>
|
||||
)}
|
||||
{confirmError && (
|
||||
<span className="note note--attn" role="alert">
|
||||
{confirmError}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Import — upload (SD-0002 §5.3). Selecting a file starts upload + validation
|
||||
// immediately (PUC-2); file-level rejections (PUC-5a) render in place with the
|
||||
// picker live for retry. No notifications — errors render here.
|
||||
import { useRef, useState } from "react";
|
||||
import { uploadImport } from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
export default function ImportUpload() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function onPick(files: FileList | null) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const resp = await uploadImport(file);
|
||||
setBusy(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
if (!resp.ok) {
|
||||
setError(resp.error.message);
|
||||
return;
|
||||
}
|
||||
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products products--narrow">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>Import products</h1>
|
||||
{error && (
|
||||
<Banner tone="attn" title="That file can't be imported">
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
disabled={busy}
|
||||
onChange={(e) => void onPick(e.target.files)}
|
||||
/>
|
||||
<span className="dropzone__title" aria-live="polite">
|
||||
{busy ? "Validating…" : "Choose a CSV file"}
|
||||
</span>
|
||||
<span className="note">CSV, up to 5,000 rows</span>
|
||||
</label>
|
||||
<p className="note">
|
||||
Works with the canonical format.{" "}
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Products page (SD-0002 §5.2) — the catalog's home: where imports start, exports
|
||||
// download, and history lives. SLICE-6 ships the export menu; the browsable list is #14's.
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
dialectLabel,
|
||||
EXPORT_STATUSES,
|
||||
exportUrl,
|
||||
getProductsSummary,
|
||||
listRuns,
|
||||
type ProductsSummary,
|
||||
type RunSummary,
|
||||
} from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
import { isExportEnabled } from "./exportMenu";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
fetching_images: "Fetching images…",
|
||||
complete: "Complete",
|
||||
complete_with_problems: "Complete with problems",
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [summary, setSummary] = useState<ProductsSummary | null>(null);
|
||||
const [runs, setRuns] = useState<RunSummary[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setFailed(false);
|
||||
const [s, r] = await Promise.all([getProductsSummary(), listRuns()]);
|
||||
if (!s.ok || !r.ok) {
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
setSummary(s.value);
|
||||
setRuns(r.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load your products">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!summary || !runs) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const empty = !isExportEnabled(summary.product_count);
|
||||
return (
|
||||
<div className="products">
|
||||
<header className="products__header">
|
||||
<h1>
|
||||
Products
|
||||
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
|
||||
</h1>
|
||||
<div className="products__actions">
|
||||
{empty ? (
|
||||
<div className="products__export">
|
||||
<button type="button" className="btn-secondary" disabled>
|
||||
Export
|
||||
</button>
|
||||
<span className="note">Export arrives when you have products</span>
|
||||
</div>
|
||||
) : (
|
||||
<details className="products__export menu">
|
||||
<summary className="btn-secondary" role="button">
|
||||
Export
|
||||
</summary>
|
||||
<ul className="menu__list">
|
||||
{EXPORT_STATUSES.map((s) => (
|
||||
<li key={s.value}>
|
||||
{/* A real download: the browser navigates to the streamed
|
||||
endpoint and saves the attachment (PUC-9). */}
|
||||
<a className="menu__item" href={exportUrl(s.value)} download>
|
||||
{s.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<a className="btn-primary" href="#/products/import">
|
||||
Import products
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{empty ? (
|
||||
<div className="empty">
|
||||
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
|
||||
<a className="btn-primary" href="#/products/import">
|
||||
Import products
|
||||
</a>
|
||||
<p className="note">
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="note">Your catalog is loaded. The browsable product list arrives with an upcoming release.</p>
|
||||
)}
|
||||
<section className="products__history">
|
||||
<h2>Import history</h2>
|
||||
{runs.length === 0 ? (
|
||||
<p className="note">No imports yet.</p>
|
||||
) : (
|
||||
<table className="datatable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className="datatable__rowlink"
|
||||
onClick={() => {
|
||||
window.location.hash = `#/products/imports/runs/${r.id}`;
|
||||
}}
|
||||
>
|
||||
<td>{new Date(r.created_at).toLocaleString()}</td>
|
||||
<td>
|
||||
{/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a
|
||||
mouse convenience. Both set the same hash, so the double fire on
|
||||
an anchor click is idempotent. */}
|
||||
<a href={`#/products/imports/runs/${r.id}`}>{r.file_name}</a>
|
||||
</td>
|
||||
<td>{dialectLabel(r.dialect)}</td>
|
||||
<td>{r.products_added}</td>
|
||||
<td>{r.products_updated}</td>
|
||||
<td>{r.rows_errored}</td>
|
||||
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
||||
// NO images section this slice (SLICE-7). PUC-4/5/8.
|
||||
import { useEffect, useState } from "react";
|
||||
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||
import { Banner } from "../../ui/kit";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
applying: "Importing…",
|
||||
fetching_images: "Fetching images…",
|
||||
complete: "Complete",
|
||||
complete_with_problems: "Complete with problems",
|
||||
};
|
||||
|
||||
export default function RunDetail({ runId }: { runId: number }) {
|
||||
const [run, setRun] = useState<RunDetailType | null>(null);
|
||||
const [loadFail, setLoadFail] = useState<"gone" | "failed" | null>(null);
|
||||
|
||||
async function load() {
|
||||
setLoadFail(null);
|
||||
const resp = await getRun(runId);
|
||||
if (!resp.ok) {
|
||||
setLoadFail(resp.status === 404 ? "gone" : "failed");
|
||||
return;
|
||||
}
|
||||
setRun(resp.value);
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runId]);
|
||||
|
||||
if (loadFail === "gone") {
|
||||
return (
|
||||
<Banner tone="attn" title="No such import run">
|
||||
<a href="#/products">← Products</a>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (loadFail === "failed") {
|
||||
return (
|
||||
<Banner tone="attn" title="Couldn't load this import run">
|
||||
Something went wrong on our side.{" "}
|
||||
<button type="button" className="linklike" onClick={() => void load()}>
|
||||
Retry
|
||||
</button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!run) {
|
||||
return (
|
||||
<p className="note" role="status">
|
||||
Loading…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="products">
|
||||
<p className="note">
|
||||
<a href="#/products">← Products</a>
|
||||
</p>
|
||||
<h1>{run.file_name}</h1>
|
||||
<p className="note">
|
||||
Imported {new Date(run.created_at).toLocaleString()} by {run.by} · {dialectLabel(run.dialect)}
|
||||
</p>
|
||||
<p className="note">
|
||||
{run.products_added} added · {run.products_updated} updated · {run.rows_errored} rows in
|
||||
error
|
||||
</p>
|
||||
<p className="note">{STATUS_LABELS[run.status] ?? run.status}</p>
|
||||
{run.errors.length > 0 && (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th>Column</th>
|
||||
<th>Problem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.errors.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.line}</td>
|
||||
<td>{e.column ?? "—"}</td>
|
||||
<td>{e.message}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EXPORT_STATUSES, exportUrl } from "../../productsApi";
|
||||
import { isExportEnabled } from "./exportMenu";
|
||||
|
||||
describe("export menu", () => {
|
||||
it("disables export for an empty catalog and enables it once there are products", () => {
|
||||
expect(isExportEnabled(0)).toBe(false);
|
||||
expect(isExportEnabled(3)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds the four status download URLs, 'all' first", () => {
|
||||
expect(EXPORT_STATUSES.map((s) => exportUrl(s.value))).toEqual([
|
||||
"/api/products/export?status=all",
|
||||
"/api/products/export?status=active",
|
||||
"/api/products/export?status=draft",
|
||||
"/api/products/export?status=archived",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// Pure logic behind the Products page Export menu (SD-0002 §5.2, PUC-9). The
|
||||
// status list + URL builder live in productsApi.ts (re-used by the component);
|
||||
// this is the one decision the menu turns on — whether export is offered at all.
|
||||
// The disclosure JSX is verified by the E2E suite, matching SLICE-2/3's pattern
|
||||
// of pure-logic unit tests + E2E for screens.
|
||||
|
||||
// Export is offered only when the catalog has products (PUC-9: an empty catalog
|
||||
// shows a disabled button + note instead).
|
||||
export function isExportEnabled(count: number): boolean {
|
||||
return count > 0;
|
||||
}
|
||||
@@ -5,3 +5,4 @@
|
||||
@import "./tokens-typography.css";
|
||||
@import "./tokens-spacing.css";
|
||||
@import "./app.css";
|
||||
@import "./products.css";
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/* Products section (SD-0002 §5) — admin nav strip, the catalog's home page, and the
|
||||
import-flow primitives (dropzone, tiles, difflist — Tasks 12–14 consume these).
|
||||
Same language as app.css: dark ground, glass chrome, hairline borders, small lifts. */
|
||||
|
||||
/* Status accents (SD-0002 design bundle): add / update / error. */
|
||||
:root {
|
||||
--st-add: #1F8A5B;
|
||||
--st-update: #B5830F;
|
||||
--st-error: #C2513E;
|
||||
}
|
||||
|
||||
/* ── admin nav: horizontal strip under the topbar ───────────────────────────── */
|
||||
.adminnav {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
padding: 0 36px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: rgba(9, 12, 34, .35);
|
||||
}
|
||||
.adminnav__item {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-mute);
|
||||
text-decoration: none;
|
||||
padding: 13px 2px 11px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.adminnav__item:hover { color: var(--wv-starlight); }
|
||||
.adminnav__item--active { color: var(--wv-starlight); border-bottom-color: var(--wv-gold); }
|
||||
|
||||
/* ── products page frame ────────────────────────────────────────────────────── */
|
||||
/* margin-bottom auto pins the page to the top of the centered .screen__main. */
|
||||
.products { width: 100%; max-width: 880px; margin-bottom: auto; }
|
||||
.products--narrow { max-width: 560px; }
|
||||
|
||||
.products__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.products__header h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
.products__count { color: var(--text-on-dark-mute); font-weight: var(--weight-medium); }
|
||||
.products__actions { display: flex; gap: 12px; align-items: center; }
|
||||
/* Disabled Export + its visible "no products yet" caption, stacked (empty catalog). */
|
||||
.products__export { display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||
.products__export .note { font-size: 11.5px; }
|
||||
|
||||
/* Export status menu (SD-0002 §5.2 — PUC-9). A native <details> disclosure so
|
||||
it's keyboard-accessible with no extra JS (§6.6). Tokens align with the design
|
||||
bundle (--surface-raised / --border-card / --radius-panel); the fallbacks keep
|
||||
it working regardless. */
|
||||
.products__export.menu {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.products__export.menu > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.products__export.menu > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.menu__list {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.25rem;
|
||||
list-style: none;
|
||||
background: var(--surface-raised, #fff);
|
||||
border: 1px solid var(--border-card, #d8d3c8);
|
||||
border-radius: var(--radius-panel, 8px);
|
||||
box-shadow: var(--shadow-soft, 0 6px 20px rgba(0, 0, 0, 0.12));
|
||||
min-width: 10rem;
|
||||
}
|
||||
.menu__item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
text-decoration: none;
|
||||
color: var(--text-on-dark-soft, inherit);
|
||||
font-family: var(--wv-font-display);
|
||||
font-size: 14px;
|
||||
}
|
||||
.menu__item:hover,
|
||||
.menu__item:focus {
|
||||
background: var(--surface-raised-hi, #f3efe7);
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
.products .btn-primary { width: auto; text-decoration: none; }
|
||||
.products .empty { margin: 24px auto 0; }
|
||||
|
||||
.products__history { margin-top: 44px; }
|
||||
.products__history h2 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-size: 17px;
|
||||
letter-spacing: var(--tracking-display);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
/* ── secondary button: outline twin of .btn-primary ─────────────────────────── */
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4em;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 15.5px;
|
||||
line-height: 1;
|
||||
padding: .85rem 1.4rem;
|
||||
border-radius: var(--radius-pill);
|
||||
border: var(--btn-border-w) solid var(--border-strong);
|
||||
background: transparent;
|
||||
color: var(--text-on-dark-soft);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
|
||||
transform var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { border-color: var(--wv-lilac); color: var(--wv-starlight); transform: var(--lift-1); }
|
||||
.btn-secondary:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.btn-secondary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||
|
||||
/* ── button that reads as a link (inline retry etc.) ────────────────────────── */
|
||||
.linklike {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--wv-lilac);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.linklike:hover { color: var(--wv-gold); }
|
||||
|
||||
/* ── data tables (import history; errortable shares the bones) ──────────────── */
|
||||
.datatable, .errortable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.datatable th, .errortable th {
|
||||
text-align: left;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 12px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-on-dark-mute);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-card);
|
||||
}
|
||||
.datatable td, .errortable td {
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
color: var(--text-on-dark-soft);
|
||||
}
|
||||
.datatable__rowlink { cursor: pointer; transition: background var(--dur-fast) var(--ease); }
|
||||
.datatable__rowlink:hover { background: var(--wv-lilac-08); }
|
||||
.errortable td:last-child { color: var(--st-error); }
|
||||
|
||||
/* ── summary tiles (preview, Task 13) ───────────────────────────────────────── */
|
||||
.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
.tile {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.tile:hover { background: var(--surface-raised-hi); }
|
||||
.tile--active { border-color: var(--wv-gold); }
|
||||
.tile__num {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: var(--text-on-dark-soft);
|
||||
}
|
||||
.tile__label { font-size: 12.5px; color: var(--text-on-dark-mute); }
|
||||
.tile--add .tile__num { color: var(--st-add); }
|
||||
.tile--update .tile__num { color: var(--st-update); }
|
||||
.tile--error .tile__num { color: var(--st-error); }
|
||||
|
||||
/* ── diff list (preview records, Task 13) ───────────────────────────────────── */
|
||||
.difflist { list-style: none; margin: 0; padding: 0; }
|
||||
.difflist__item { padding: 12px 4px; border-bottom: 1px solid var(--border-soft); }
|
||||
.difflist__item > summary {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-soft);
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.difflist__item > summary:hover { color: var(--wv-starlight); }
|
||||
.difflist__item[open] > summary { margin-bottom: 8px; }
|
||||
.difflist__handle { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 12.5px; }
|
||||
.diffchange {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-on-dark-soft);
|
||||
}
|
||||
.diffchange--head { color: var(--text-on-dark-mute); margin-top: 6px; }
|
||||
.diffchange__glyph--add { color: var(--st-add); }
|
||||
.diffchange__glyph--del { color: var(--st-error); }
|
||||
|
||||
/* ── kind chip (preview record summaries, Task 13) ──────────────────────────── */
|
||||
.kindchip {
|
||||
display: inline-block;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 11px;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
padding: 3px 9px 2px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-on-dark-mute);
|
||||
}
|
||||
.kindchip--add { color: var(--st-add); border-color: var(--st-add); }
|
||||
.kindchip--update { color: var(--st-update); border-color: var(--st-update); }
|
||||
.kindchip--error { color: var(--st-error); border-color: var(--st-error); }
|
||||
|
||||
/* preview layout rhythm: tiles + errortable sit between header and difflist */
|
||||
.products .tiles { margin: 24px 0 18px; }
|
||||
.products .errortable { margin: 0 0 18px; }
|
||||
|
||||
/* ── sticky confirm/cancel footer (preview, Task 13) ────────────────────────── */
|
||||
.sticky-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
background: var(--glass-sky);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
}
|
||||
|
||||
/* ── upload dropzone (Task 12) ──────────────────────────────────────────────── */
|
||||
.dropzone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
border: 2px dashed var(--border-strong);
|
||||
border-radius: var(--radius-card);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.dropzone:hover { border-color: var(--wv-lilac); background: var(--wv-lilac-08); }
|
||||
.dropzone--busy { opacity: .55; pointer-events: none; }
|
||||
.dropzone input[type="file"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropzone__title {
|
||||
display: block;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 17px;
|
||||
color: var(--text-on-dark-soft);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* ── small screens ──────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.adminnav { padding: 0 20px; }
|
||||
.tiles { grid-template-columns: repeat(2, 1fr); }
|
||||
.products__header { flex-wrap: wrap; }
|
||||
}
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E browser gate (SD-0002 §6.8) — Playwright against a fresh local stack.
|
||||
# Not yet part of check.sh/CI: the CI runner has no browsers (§10.6 gap).
|
||||
set -euo pipefail
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo_root/e2e"
|
||||
if [ ! -d node_modules ]; then
|
||||
npm install
|
||||
npx playwright install chromium
|
||||
fi
|
||||
npx playwright test "$@"
|
||||
Reference in New Issue
Block a user