Pivot: strip storefront → network-service seed (v0.9.0) #33
+3
-12
@@ -1,8 +1,8 @@
|
||||
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
|
||||
# the local and server gates can never drift: import-linter (main > domains >
|
||||
# platform), then backend pytest against a Postgres service, then the frontend
|
||||
# typecheck + build. Requires a registered Gitea Actions runner advertising the
|
||||
# `ubuntu-latest` label.
|
||||
# platform), then backend pytest against a Postgres service. The network-seed reset
|
||||
# stripped the SPA frontend, so the gate is backend-only now. Requires a registered
|
||||
# Gitea Actions runner advertising the `ubuntu-latest` label.
|
||||
name: ci
|
||||
|
||||
on:
|
||||
@@ -35,21 +35,12 @@ jobs:
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install backend deps
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install frontend deps
|
||||
run: |
|
||||
cd frontend && npm ci
|
||||
|
||||
- name: Run the pre-merge gate
|
||||
env:
|
||||
PYTHON: ${{ github.workspace }}/.venv/bin/python
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
# wiggleverse-ecomm
|
||||
|
||||
The clean implementation of Wiggleverse ecomm — a multi-tenant, OHM-grounded
|
||||
Shopify alternative. Greenfield: started fresh 2026-06-10, superseding the
|
||||
first-pass reference prototype (`wiggleverse-ecomm-prototype`).
|
||||
Wiggleverse ecomm, pivoted to a network-service seed — a minimal, OHM-grounded
|
||||
FastAPI service for a cross-maker verified referral network. Greenfield: started
|
||||
fresh 2026-06-10, superseding the first-pass reference prototype
|
||||
(`wiggleverse-ecomm-prototype`). The network-seed reset (v0.9.0) stripped the
|
||||
storefront/products HTTP spine and the SPA; the seed is `/healthz` + `/api/auth/*`
|
||||
(accounts) + the catalog-normalization library (no HTTP surface yet) + `platform/*`.
|
||||
|
||||
- **One Name:** `ecomm` (see `app.json`).
|
||||
- **Content/corpus repo:** `wiggleverse-ecomm-content` (specs, plans, roadmap, pin).
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
# wiggleverse-ecomm
|
||||
|
||||
The clean implementation of **Wiggleverse ecomm** — a multi-tenant, OHM-grounded
|
||||
Shopify alternative.
|
||||
**Wiggleverse ecomm** is being pivoted to a **network-service seed**: a minimal,
|
||||
OHM-grounded FastAPI service for a cross-maker verified referral network. The
|
||||
storefront / products import-export spine and the SPA frontend were stripped in the
|
||||
network-seed reset (v0.9.0); what remains is `/healthz`, the `/api/auth/*` identity
|
||||
endpoints (the `accounts` domain), the catalog-normalization library kept as
|
||||
importable modules with no HTTP surface yet, and the generic `platform/*` infra.
|
||||
|
||||
This repo is greenfield. It was stood up fresh on 2026-06-10, superseding the
|
||||
first-pass **reference prototype** at
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": "1.1",
|
||||
"name": "ecomm",
|
||||
"title": "Wiggleverse ecomm — multi-tenant commerce",
|
||||
"title": "Wiggleverse ecomm — cross-maker verified referral network (service)",
|
||||
"giteaHost": "ssh://git@git.wiggleverse.org:2222",
|
||||
"repos": [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""domains layer — bounded contexts (accounts, storefronts).
|
||||
"""domains layer — bounded contexts (accounts, products).
|
||||
|
||||
Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3.
|
||||
The package exists now so the layer contract (.importlinter) has a target and later
|
||||
slices add to a stable seam. Domains import only from app.platform, never upward.
|
||||
The accounts domain is the network-service seed's identity context. The products
|
||||
package is the catalog-normalization library (no HTTP surface yet). Domains import
|
||||
only from app.platform, never upward.
|
||||
"""
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
|
||||
"""products domain — catalog-normalization library (no HTTP surface yet).
|
||||
|
||||
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.
|
||||
The network-seed reset stripped the storefront-scoped import/export/image spine
|
||||
(service/repo/imagefetch and their endpoints). What remains is the pure, DB-free
|
||||
catalog-normalization library: the canonical row model, the CSV codec, the Shopify
|
||||
dialect adapter, validation, the diff engine, and serialization. These submodules are
|
||||
importable as a library; nothing here owns persistence or an HTTP route.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .imagefetch import recover_incomplete_runs, run_image_phase
|
||||
from .codec import detect_dialect, parse_csv
|
||||
from .diff import (
|
||||
CatalogImage,
|
||||
CatalogProduct,
|
||||
CatalogVariant,
|
||||
DiffResult,
|
||||
ImagePlan,
|
||||
ProductPlan,
|
||||
VariantPlan,
|
||||
compute_diff,
|
||||
)
|
||||
from .dialect_shopify import is_shopify_header, map_shopify_header
|
||||
from .errors import (
|
||||
DraftExpired,
|
||||
DraftNotFound,
|
||||
@@ -19,30 +32,37 @@ from .errors import (
|
||||
ProductsError,
|
||||
RunNotFound,
|
||||
)
|
||||
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||
from .repo import image_for_serving
|
||||
from .service import (
|
||||
confirm_draft,
|
||||
discard_draft,
|
||||
export_catalog,
|
||||
get_draft,
|
||||
get_draft_records,
|
||||
get_run,
|
||||
import_validate,
|
||||
list_runs,
|
||||
summary,
|
||||
from .models import (
|
||||
MAX_DATA_ROWS,
|
||||
MAX_FILE_BYTES,
|
||||
CanonicalImage,
|
||||
CanonicalProduct,
|
||||
CanonicalVariant,
|
||||
ParsedFile,
|
||||
Row,
|
||||
RowError,
|
||||
)
|
||||
from .serialize import catalog_to_csv
|
||||
from .validate import all_errors, build_products
|
||||
|
||||
# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
|
||||
# DOC-3: the downloadable worked-example CSV.
|
||||
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
|
||||
# DOC-2: the column reference the BFF serves at /api/products/columns.md.
|
||||
# DOC-2: the column reference.
|
||||
COLUMNS_MD_PATH = Path(__file__).parent / "columns.md"
|
||||
|
||||
__all__ = [
|
||||
# errors
|
||||
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
|
||||
"PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog",
|
||||
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", "COLUMNS_MD_PATH",
|
||||
"import_validate", "get_draft", "get_draft_records", "discard_draft",
|
||||
"confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
|
||||
"run_image_phase", "recover_incomplete_runs", "image_for_serving",
|
||||
# models + limits
|
||||
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "Row", "RowError", "ParsedFile",
|
||||
"CanonicalProduct", "CanonicalVariant", "CanonicalImage",
|
||||
# codec + dialect
|
||||
"parse_csv", "detect_dialect", "is_shopify_header", "map_shopify_header",
|
||||
# validate + diff + serialize
|
||||
"build_products", "all_errors", "compute_diff", "catalog_to_csv",
|
||||
"CatalogProduct", "CatalogVariant", "CatalogImage",
|
||||
"ProductPlan", "VariantPlan", "ImagePlan", "DiffResult",
|
||||
# docs
|
||||
"SAMPLE_CSV_PATH", "COLUMNS_MD_PATH",
|
||||
]
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""The post-commit image-fetch phase (SD-0002 §6.5.4, §6.9).
|
||||
|
||||
In-process, bounded-concurrency, resumable. For a run's pending images: fetch
|
||||
each URL behind an SSRF guard with INV-18 bounds, run platform/images, store
|
||||
renditions via the objectstore, and move the image pending -> fetched |
|
||||
rejected_* | failed. Per-image idempotent (claim guard), so the phase can die
|
||||
and resume. Lives in the domains layer (imports repo + platform); main.py
|
||||
schedules it post-confirm and runs recovery at startup.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from app.platform import images as images_mod
|
||||
from app.platform import telemetry
|
||||
|
||||
from . import repo
|
||||
|
||||
MAX_FETCH_BYTES = 20 * 1024 * 1024 # INV-18
|
||||
FETCH_TIMEOUT_S = 30 # INV-18
|
||||
FETCH_CONCURRENCY = 4 # §6.6
|
||||
_CONTENT_PREFIX = "image/"
|
||||
|
||||
|
||||
class FetchBlocked(Exception):
|
||||
"""SSRF guard or bounds refusal — maps to image status 'failed' with reason."""
|
||||
|
||||
|
||||
def _guard_host(url: str, allow_private: bool) -> None:
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme not in {"http", "https"}:
|
||||
raise FetchBlocked(f"unsupported scheme: {parts.scheme!r}")
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
raise FetchBlocked("no host")
|
||||
if allow_private:
|
||||
return
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80))
|
||||
except socket.gaierror as exc:
|
||||
raise FetchBlocked(f"dns failure: {exc}") from exc
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
raise FetchBlocked(f"non-public address: {ip}")
|
||||
|
||||
|
||||
def fetch_bytes(url: str, allow_private: bool) -> tuple[bytes, str]:
|
||||
"""SSRF-guarded, bounded GET. Returns (bytes, content_type). Raises FetchBlocked."""
|
||||
_guard_host(url, allow_private)
|
||||
with httpx.Client(follow_redirects=False, timeout=FETCH_TIMEOUT_S) as client:
|
||||
with client.stream("GET", url) as resp:
|
||||
if resp.status_code != 200:
|
||||
raise FetchBlocked(f"http {resp.status_code}")
|
||||
ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if not ctype.startswith(_CONTENT_PREFIX):
|
||||
raise FetchBlocked(f"not an image content-type: {ctype!r}")
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
for chunk in resp.iter_bytes():
|
||||
total += len(chunk)
|
||||
if total > MAX_FETCH_BYTES:
|
||||
raise FetchBlocked("oversize")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks), ctype
|
||||
|
||||
|
||||
def _key(storefront_id: int, image_id: int, name: str) -> str:
|
||||
return f"storefronts/{storefront_id}/product-images/{image_id}/{name}"
|
||||
|
||||
|
||||
def _process_one(pool, store, image: dict, allow_private: bool) -> None:
|
||||
with pool.connection() as conn:
|
||||
claimed = repo.claim_image_for_fetch(conn, image["id"])
|
||||
conn.commit()
|
||||
if not claimed:
|
||||
return
|
||||
image_id, sf = image["id"], image["storefront_id"]
|
||||
try:
|
||||
data, ctype = fetch_bytes(image["source_url"], allow_private)
|
||||
except (FetchBlocked, httpx.HTTPError) as exc:
|
||||
with pool.connection() as conn:
|
||||
repo.mark_image_failed(conn, image_id, str(exc)[:200]); conn.commit()
|
||||
return
|
||||
try:
|
||||
result = images_mod.process(data)
|
||||
if isinstance(result, images_mod.Rejected):
|
||||
reason = ("below the resolution bar" if result.reason == "rejected_low_res"
|
||||
else "not a supported image")
|
||||
with pool.connection() as conn:
|
||||
repo.mark_image_rejected(conn, image_id, result.reason, reason); conn.commit()
|
||||
return
|
||||
keys = {"original": _key(sf, image_id, "original"),
|
||||
"thumb": _key(sf, image_id, "thumb.webp"),
|
||||
"card": _key(sf, image_id, "card.webp"),
|
||||
"detail": _key(sf, image_id, "detail.webp")}
|
||||
store.put(keys["original"], data, ctype)
|
||||
store.put(keys["thumb"], result.renditions["thumb"], "image/webp")
|
||||
store.put(keys["card"], result.renditions["card"], "image/webp")
|
||||
store.put(keys["detail"], result.renditions["detail"], "image/webp")
|
||||
with pool.connection() as conn:
|
||||
repo.mark_image_fetched(conn, image_id, keys); conn.commit()
|
||||
except Exception as exc: # noqa: BLE001 — one bad image must never wedge the run (review fix)
|
||||
with pool.connection() as conn:
|
||||
repo.mark_image_failed(conn, image_id, f"processing error: {type(exc).__name__}")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def run_image_phase(pool, store, run_id: int, allow_private: bool) -> None:
|
||||
"""Fetch all pending images of one run, then move the run to a terminal status."""
|
||||
started = time.monotonic()
|
||||
with pool.connection() as conn:
|
||||
pending = repo.pending_images_for_run(conn, run_id)
|
||||
if pending:
|
||||
with ThreadPoolExecutor(max_workers=FETCH_CONCURRENCY) as pool_x:
|
||||
list(pool_x.map(lambda img: _process_one(pool, store, img, allow_private), pending))
|
||||
with pool.connection() as conn:
|
||||
counts = repo.run_image_counts(conn, run_id)
|
||||
status = ("complete_with_problems"
|
||||
if counts["rejected"] + counts["failed"] > 0 else "complete")
|
||||
repo.set_run_status(conn, run_id, status)
|
||||
conn.commit()
|
||||
telemetry.emit("image_phase_completed", run_id=run_id, fetched=counts["fetched"],
|
||||
rejected=counts["rejected"], failed=counts["failed"],
|
||||
duration_ms=int((time.monotonic() - started) * 1000))
|
||||
|
||||
|
||||
def recover_incomplete_runs(pool, store, allow_private: bool) -> list[int]:
|
||||
"""Startup scan (§6.9): resume any run stuck in fetching_images. Returns the ids."""
|
||||
with pool.connection() as conn:
|
||||
runs = repo.incomplete_runs(conn)
|
||||
resumed: list[int] = []
|
||||
for run in runs:
|
||||
with pool.connection() as conn:
|
||||
pending = len(repo.pending_images_for_run(conn, run["id"]))
|
||||
telemetry.emit("image_phase_recovered", run_id=run["id"], pending_resumed=pending)
|
||||
run_image_phase(pool, store, run["id"], allow_private)
|
||||
resumed.append(run["id"])
|
||||
return resumed
|
||||
@@ -1,616 +0,0 @@
|
||||
"""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, i.status"
|
||||
" 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], status=row[5])
|
||||
)
|
||||
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()
|
||||
runs = [_run_dict(row) for row in rows]
|
||||
if not runs:
|
||||
return runs
|
||||
ids = [r["id"] for r in runs]
|
||||
by_run = {rid: {"fetched": 0, "rejected": 0, "failed": 0, "total": 0} for rid in ids}
|
||||
for rid, fetched, rejected, failed, total in conn.execute(
|
||||
"SELECT import_run_id,"
|
||||
" count(*) FILTER (WHERE status='fetched'),"
|
||||
" count(*) FILTER (WHERE status IN ('rejected_low_res','rejected_not_image')),"
|
||||
" count(*) FILTER (WHERE status='failed'), count(*)"
|
||||
" FROM product_image WHERE import_run_id = ANY(%s) GROUP BY import_run_id",
|
||||
(ids,),
|
||||
):
|
||||
by_run[rid] = {"fetched": fetched, "rejected": rejected, "failed": failed, "total": total}
|
||||
for r in runs:
|
||||
r["image_counts"] = by_run[r["id"]]
|
||||
return runs
|
||||
|
||||
|
||||
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,),
|
||||
)
|
||||
]
|
||||
counts = run_image_counts(conn, run_id)
|
||||
run["image_progress"] = {"done": counts["total"] - counts["pending"], "total": counts["total"]}
|
||||
run["image_counts"] = {"fetched": counts["fetched"], "rejected": counts["rejected"],
|
||||
"failed": counts["failed"]}
|
||||
run["image_outcomes"] = run_image_outcomes(conn, run_id)
|
||||
return run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image fetch phase (SLICE-7, SD-0002 §6.5.4) — claim/mark/count/outcomes.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pending_images_for_run(conn: psycopg.Connection, run_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT i.id, i.source_url, p.storefront_id, i.product_id"
|
||||
" FROM product_image i JOIN product p ON p.id = i.product_id"
|
||||
" WHERE i.import_run_id = %s AND i.status = 'pending' ORDER BY i.id",
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
return [{"id": r[0], "source_url": r[1], "storefront_id": r[2], "product_id": r[3]} for r in rows]
|
||||
|
||||
|
||||
def claim_image_for_fetch(conn: psycopg.Connection, image_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"UPDATE product_image SET status = 'pending' WHERE id = %s AND status = 'pending' RETURNING id",
|
||||
(image_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_image_fetched(conn: psycopg.Connection, image_id: int, keys: dict[str, str]) -> None:
|
||||
conn.execute(
|
||||
"UPDATE product_image SET status = 'fetched', failure_reason = NULL,"
|
||||
" key_original = %s, key_thumb = %s, key_card = %s, key_detail = %s,"
|
||||
" fetched_at = now() WHERE id = %s",
|
||||
(keys["original"], keys["thumb"], keys["card"], keys["detail"], image_id),
|
||||
)
|
||||
|
||||
|
||||
def mark_image_rejected(conn: psycopg.Connection, image_id: int, status: str, reason: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE product_image SET status = %s, failure_reason = %s, fetched_at = now() WHERE id = %s",
|
||||
(status, reason, image_id),
|
||||
)
|
||||
|
||||
|
||||
def mark_image_failed(conn: psycopg.Connection, image_id: int, reason: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE product_image SET status = 'failed', failure_reason = %s, fetched_at = now() WHERE id = %s",
|
||||
(reason, image_id),
|
||||
)
|
||||
|
||||
|
||||
def run_image_counts(conn: psycopg.Connection, run_id: int) -> dict:
|
||||
row = conn.execute(
|
||||
"SELECT count(*) FILTER (WHERE status = 'fetched'),"
|
||||
" count(*) FILTER (WHERE status IN ('rejected_low_res', 'rejected_not_image')),"
|
||||
" count(*) FILTER (WHERE status = 'failed'),"
|
||||
" count(*) FILTER (WHERE status = 'pending'), count(*)"
|
||||
" FROM product_image WHERE import_run_id = %s",
|
||||
(run_id,),
|
||||
).fetchone()
|
||||
return {"fetched": row[0], "rejected": row[1], "failed": row[2], "pending": row[3], "total": row[4]}
|
||||
|
||||
|
||||
def set_run_status(conn: psycopg.Connection, run_id: int, status: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE import_run SET status = %s,"
|
||||
" completed_at = CASE WHEN %s THEN now() ELSE completed_at END WHERE id = %s",
|
||||
(status, status in _TERMINAL_RUN_STATUSES, run_id),
|
||||
)
|
||||
|
||||
|
||||
def incomplete_runs(conn: psycopg.Connection) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, storefront_id FROM import_run WHERE status = 'fetching_images' ORDER BY id",
|
||||
).fetchall()
|
||||
return [{"id": r[0], "storefront_id": r[1]} for r in rows]
|
||||
|
||||
|
||||
def image_for_serving(conn: psycopg.Connection, storefront_id: int, image_id: int) -> dict | None:
|
||||
"""Storefront-scoped lookup for the serving route: status + rendition keys (INV-14)."""
|
||||
row = conn.execute(
|
||||
"SELECT i.status, i.key_original, i.key_thumb, i.key_card, i.key_detail"
|
||||
" FROM product_image i JOIN product p ON p.id = i.product_id"
|
||||
" WHERE i.id = %s AND p.storefront_id = %s",
|
||||
(image_id, storefront_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {"status": row[0], "original": row[1], "thumb": row[2], "card": row[3], "detail": row[4]}
|
||||
|
||||
|
||||
def run_image_outcomes(conn: psycopg.Connection, run_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
"SELECT p.handle, i.source_url, i.status, i.failure_reason,"
|
||||
" v.option1_value, v.option2_value, v.option3_value"
|
||||
" FROM product_image i JOIN product p ON p.id = i.product_id"
|
||||
" LEFT JOIN variant v ON v.image_id = i.id"
|
||||
" WHERE i.import_run_id = %s"
|
||||
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')"
|
||||
" ORDER BY p.handle, i.position, i.id",
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"handle": r[0], "url": r[1], "outcome": r[2], "reason": r[3],
|
||||
"variant": " / ".join(x for x in (r[4], r[5], r[6]) if x) or None}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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])
|
||||
@@ -1,265 +0,0 @@
|
||||
"""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 config, 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="applying",
|
||||
)
|
||||
for plan in diff_result.plan:
|
||||
_apply_product_plan(conn, storefront_id, plan, run_id)
|
||||
repo.insert_run_errors(conn, run_id, error_rows)
|
||||
pending = repo.run_image_counts(conn, run_id)["pending"]
|
||||
repo.set_run_status(conn, run_id, "fetching_images" if pending else "complete")
|
||||
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, base_url=config.public_base_url())
|
||||
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),
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
"""storefronts domain — storefront entity, membership, the one-storefront guard.
|
||||
|
||||
Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows
|
||||
carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported
|
||||
via this package surface only (§6.2).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .errors import AlreadyOwnsStorefront, StorefrontsError
|
||||
from .models import Storefront
|
||||
from .service import create_storefront, storefront_for
|
||||
|
||||
__all__ = [
|
||||
"Storefront",
|
||||
"StorefrontsError",
|
||||
"AlreadyOwnsStorefront",
|
||||
"create_storefront",
|
||||
"storefront_for",
|
||||
]
|
||||
@@ -1,10 +0,0 @@
|
||||
"""storefronts domain errors."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class StorefrontsError(Exception):
|
||||
"""Base for storefronts-domain errors."""
|
||||
|
||||
|
||||
class AlreadyOwnsStorefront(StorefrontsError):
|
||||
"""INV-4: the account already has its one storefront (PUC-7)."""
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Storefront record — the §6.3 entity as the domain returns it."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Storefront:
|
||||
id: int
|
||||
name: str
|
||||
@@ -1,56 +0,0 @@
|
||||
"""storefronts — the storefront + membership service (SD-0001 §6.5).
|
||||
|
||||
Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing
|
||||
answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that
|
||||
makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes
|
||||
account_id and email down.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg
|
||||
|
||||
from .errors import AlreadyOwnsStorefront
|
||||
from .models import Storefront
|
||||
|
||||
|
||||
def _default_name(email: str) -> str:
|
||||
"""§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026)."""
|
||||
return f"{email.split('@', 1)[0]}'s storefront"
|
||||
|
||||
|
||||
def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None:
|
||||
"""The entry-routing answer (§6.5): which storefront, if any, this account has."""
|
||||
row = conn.execute(
|
||||
"SELECT s.id, s.name FROM storefront s"
|
||||
" JOIN storefront_membership m ON m.storefront_id = s.id"
|
||||
" WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1",
|
||||
(account_id,),
|
||||
).fetchone()
|
||||
return Storefront(id=row[0], name=row[1]) if row else None
|
||||
|
||||
|
||||
def create_storefront(
|
||||
conn: psycopg.Connection, account_id: int, email: str, name: str | None
|
||||
) -> Storefront:
|
||||
"""Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5).
|
||||
|
||||
Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by
|
||||
account_id serializes concurrent creates for the same account, so the second of two
|
||||
racing requests sees the first's committed membership and is refused (§6.5).
|
||||
"""
|
||||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,))
|
||||
existing = storefront_for(conn, account_id)
|
||||
if existing is not None:
|
||||
conn.rollback() # release the advisory lock; nothing was written
|
||||
raise AlreadyOwnsStorefront()
|
||||
final_name = (name or "").strip() or _default_name(email)
|
||||
sf_id = conn.execute(
|
||||
"INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,)
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO storefront_membership (account_id, storefront_id, role)"
|
||||
" VALUES (%s, %s, 'owner')",
|
||||
(account_id, sf_id),
|
||||
)
|
||||
conn.commit()
|
||||
return Storefront(id=sf_id, name=final_name)
|
||||
+17
-303
@@ -1,32 +1,31 @@
|
||||
"""ecomm backend — FastAPI app factory + the REST BFF.
|
||||
"""ecomm backend — FastAPI app factory + the network-service seed BFF.
|
||||
|
||||
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
|
||||
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. SLICE-5 adds the /api/products/* import
|
||||
spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping.
|
||||
This is the pivot to a network-service seed: a minimal FastAPI surface carrying only
|
||||
/healthz and the /api/auth/* identity endpoints (§6.4), backed by the accounts domain.
|
||||
The BFF 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 — auth needs the mailer) at startup.
|
||||
|
||||
The catalog-normalization library (app.domains.products: codec/dialect/models/serialize/
|
||||
validate/diff) is kept as importable modules but has no HTTP surface yet — the storefront
|
||||
and products import/export/image spine was stripped in the network-seed reset.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psycopg
|
||||
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||
from fastapi import Path as ApiPath
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi import Depends, FastAPI, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.domains import accounts, products, storefronts
|
||||
from app.domains import accounts
|
||||
from app.platform import config, db
|
||||
from app.platform import mailer as mailer_mod
|
||||
from app.platform import objectstore as objectstore_mod
|
||||
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
||||
from app.platform.mailer import Mailer
|
||||
from app.platform import session as session_mod
|
||||
@@ -50,41 +49,11 @@ class VerifyBody(BaseModel):
|
||||
code: str
|
||||
|
||||
|
||||
class CreateStorefrontBody(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse:
|
||||
"""The shared §6.4 error envelope: {"error": {"code", "message", ...}}."""
|
||||
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}})
|
||||
|
||||
|
||||
def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None:
|
||||
"""The entry-routing answer: which storefront, if any, this account has (§6.5)."""
|
||||
sf = storefronts.storefront_for(conn, account.id)
|
||||
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).
|
||||
|
||||
@@ -114,7 +83,7 @@ def _set_session_cookie(response: Response, account: accounts.Account) -> None:
|
||||
)
|
||||
|
||||
|
||||
def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI:
|
||||
def create_app(database_url: str | None = None) -> FastAPI:
|
||||
_ensure_app_logging()
|
||||
dsn = database_url or config.database_url()
|
||||
|
||||
@@ -124,18 +93,9 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
||||
with app.state.pool.connection() as conn:
|
||||
db.migrate(conn) # self-migrate at startup (INV-1, INV-7)
|
||||
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
|
||||
app.state.objectstore = objectstore_mod.build_objectstore(config.objectstore_kind())
|
||||
app.state.image_allow_private = config.image_fetch_allow_private()
|
||||
app.state.image_runner = ThreadPoolExecutor(max_workers=2, thread_name_prefix="img-run")
|
||||
# §6.9 startup recovery — resume runs stuck mid image-fetch, off the request path.
|
||||
app.state.image_runner.submit(
|
||||
products.recover_incomplete_runs, app.state.pool,
|
||||
app.state.objectstore, app.state.image_allow_private,
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
app.state.image_runner.shutdown(wait=False)
|
||||
app.state.pool.close()
|
||||
|
||||
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
|
||||
@@ -180,7 +140,7 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
||||
|
||||
@app.post("/api/auth/verify")
|
||||
def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)):
|
||||
"""Verify a code, start a session, and answer entry routing (§6.4/§6.5)."""
|
||||
"""Verify a code and start a session (§6.4)."""
|
||||
try:
|
||||
account, created = accounts.verify(conn, body.email, body.code)
|
||||
except accounts.InvalidEmail:
|
||||
@@ -196,7 +156,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
||||
return _error(400, "code_exhausted", "Too many attempts — request a fresh code.")
|
||||
payload = {
|
||||
"account": {"email": account.email},
|
||||
"storefront": _storefront_for(conn, account),
|
||||
"created": created,
|
||||
}
|
||||
resp = JSONResponse(status_code=200, content=payload)
|
||||
@@ -205,13 +164,13 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)):
|
||||
"""The signed-in account + entry-routing answer, or 401 (§6.4/§6.5)."""
|
||||
"""The signed-in account, or 401 (§6.4)."""
|
||||
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.")
|
||||
return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)}
|
||||
return {"account": {"email": account.email}}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
def logout():
|
||||
@@ -220,251 +179,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None =
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
@app.post("/api/storefronts")
|
||||
def create_storefront(
|
||||
body: CreateStorefrontBody,
|
||||
conn: psycopg.Connection = Depends(get_conn),
|
||||
sess: dict | None = Depends(get_session),
|
||||
):
|
||||
"""Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal)."""
|
||||
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.")
|
||||
try:
|
||||
sf = storefronts.create_storefront(conn, account.id, account.email, body.name)
|
||||
except storefronts.AlreadyOwnsStorefront:
|
||||
return _error(
|
||||
409, "already_owns_storefront",
|
||||
"Your account already has its storefront — ecomm is one storefront per account today.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
app.state.image_runner.submit(
|
||||
products.run_image_phase, app.state.pool, app.state.objectstore,
|
||||
run_id, app.state.image_allow_private,
|
||||
)
|
||||
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"'},
|
||||
)
|
||||
|
||||
_RENDITION_CT = {"thumb": "image/webp", "card": "image/webp",
|
||||
"detail": "image/webp", "original": "application/octet-stream"}
|
||||
|
||||
@app.get("/api/products/images/{image_id}/{rendition}")
|
||||
def serve_product_image(
|
||||
image_id: int,
|
||||
rendition: str = ApiPath(pattern="^(original|thumb|card|detail)$"),
|
||||
conn: psycopg.Connection = Depends(get_conn),
|
||||
sess: dict | None = Depends(get_session),
|
||||
):
|
||||
"""Serve a hosted image rendition, storefront-authorized + immutable cache (§6.4, INV-16)."""
|
||||
gate = _merchant_gate(conn, sess)
|
||||
if isinstance(gate, JSONResponse):
|
||||
return gate
|
||||
_account, sf = gate
|
||||
rec = products.image_for_serving(conn, sf.id, image_id)
|
||||
if rec is None:
|
||||
return _error(404, "not_found", "No such image.")
|
||||
if rec["status"] != "fetched":
|
||||
return _error(409, "not_fetched", "This image has not been fetched yet.")
|
||||
key = rec[rendition]
|
||||
if not key:
|
||||
return _error(404, "not_found", "No such rendition.")
|
||||
try:
|
||||
data = app.state.objectstore.get(key)
|
||||
except objectstore_mod.ObjectNotFound:
|
||||
return _error(404, "not_found", "No such image.")
|
||||
return Response(content=data, media_type=_RENDITION_CT[rendition],
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||
|
||||
@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"'},
|
||||
)
|
||||
|
||||
@app.get("/api/products/columns.md")
|
||||
def products_columns_md():
|
||||
"""The DOC-2 column reference. Documentation, so no auth gate (§6.4)."""
|
||||
return PlainTextResponse(
|
||||
products.COLUMNS_MD_PATH.read_text(),
|
||||
media_type="text/markdown; charset=utf-8",
|
||||
)
|
||||
|
||||
# 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.
|
||||
spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist"
|
||||
if (spa_dir / "index.html").is_file():
|
||||
app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True}
|
||||
assert data == {"account": {"email": "merchant@example.com"}, "created": True}
|
||||
assert "ecomm_session" in resp.cookies
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_me_returns_account_with_session(fresh_db_url):
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None}
|
||||
assert resp.json() == {"account": {"email": "merchant@example.com"}}
|
||||
|
||||
|
||||
def test_puc_09_logout_clears_session(fresh_db_url):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole
|
||||
flow — request-code -> verify -> create-storefront -> /me — asserting no step needed
|
||||
seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py."""
|
||||
surviving flow — request-code -> verify -> /me — asserting no step needed seeded state.
|
||||
The network-seed reset removed the create-storefront step from this flow. Migration
|
||||
idempotence (the second INV-1 test) lives in test_migrations.py."""
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -26,17 +27,9 @@ def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["created"] is True
|
||||
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
|
||||
assert verified.json()["account"] == {"email": "first@example.com"}
|
||||
|
||||
# PUC-4: create the storefront (blank name -> generated default)
|
||||
created = client.post("/api/storefronts", json={})
|
||||
assert created.status_code == 201
|
||||
assert created.json()["name"] == "first's storefront"
|
||||
|
||||
# PUC-6/PUC-8: the admin answer — storefront + email from /me alone
|
||||
# the admin answer — the signed-in account from /me alone (cookie set by verify)
|
||||
me = client.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json() == {
|
||||
"account": {"email": "first@example.com"},
|
||||
"storefront": created.json(),
|
||||
}
|
||||
assert me.json() == {"account": {"email": "first@example.com"}}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
"""§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_columns_md_served(fresh_db_url):
|
||||
"""DOC-2 column reference is app-served, unauthenticated documentation (§6.4)."""
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
resp = client.get("/api/products/columns.md")
|
||||
assert resp.status_code == 200
|
||||
assert "text/markdown" in resp.headers["content-type"]
|
||||
body = resp.text
|
||||
assert "Body (HTML)" in body # Shopify dialect notes present
|
||||
assert "`Handle`" in body # canonical columns present
|
||||
|
||||
|
||||
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
|
||||
@@ -1,100 +0,0 @@
|
||||
"""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]
|
||||
@@ -1,30 +0,0 @@
|
||||
"""hosted-image URL helpers — round-trip recognition (SD-0002 §6.3.1, INV-12)."""
|
||||
from app.domains.products import hosted
|
||||
|
||||
|
||||
def test_build_relative_when_no_base():
|
||||
assert hosted.image_url("", 42, "detail") == "/api/products/images/42/detail"
|
||||
|
||||
|
||||
def test_build_absolute_with_base():
|
||||
url = hosted.image_url("https://ecomm-ppe.wiggleverse.org", 42, "detail")
|
||||
assert url == "https://ecomm-ppe.wiggleverse.org/api/products/images/42/detail"
|
||||
|
||||
|
||||
def test_parse_absolute_hosted_url():
|
||||
url = "https://market.wiggleverse.org/api/products/images/7/detail"
|
||||
assert hosted.parse_image_id(url) == 7
|
||||
|
||||
|
||||
def test_parse_relative_hosted_url():
|
||||
assert hosted.parse_image_id("/api/products/images/7/card") == 7
|
||||
|
||||
|
||||
def test_parse_ignores_query_and_trailing():
|
||||
assert hosted.parse_image_id("/api/products/images/7/detail?v=1") == 7
|
||||
|
||||
|
||||
def test_parse_non_hosted_returns_none():
|
||||
assert hosted.parse_image_id("https://cdn.example.com/a/b.png") is None
|
||||
assert hosted.parse_image_id("/api/products/images/abc/detail") is None
|
||||
assert hosted.parse_image_id("/api/products/images/7") is None
|
||||
@@ -1,78 +0,0 @@
|
||||
"""GET /api/products/images/{id}/{rendition} — authorize, stream, immutable cache (§6.4, INV-14/16)."""
|
||||
import psycopg
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.domains import products # noqa: F401 (ensures package import path)
|
||||
|
||||
from test_products_endpoints import _merchant_client # reuse the signed-in client
|
||||
|
||||
|
||||
def _seed_image(fresh_db_url, status="fetched", with_detail_bytes=None, store=None,
|
||||
email_storefront_only=True):
|
||||
"""Insert a product + image for the (only) storefront; set keys; return (storefront_id, image_id, keys)."""
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
sf = conn.execute("SELECT id FROM storefront ORDER BY id LIMIT 1").fetchone()[0]
|
||||
pid = conn.execute(
|
||||
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id",
|
||||
(sf,)).fetchone()[0]
|
||||
iid = conn.execute(
|
||||
"INSERT INTO product_image (product_id, source_url, position, status)"
|
||||
" VALUES (%s,'https://m/a.png',1,%s) RETURNING id", (pid, status)).fetchone()[0]
|
||||
keys = {r: f"storefronts/{sf}/product-images/{iid}/{r}" for r in ("original", "thumb", "card", "detail")}
|
||||
if status == "fetched":
|
||||
conn.execute(
|
||||
"UPDATE product_image SET key_original=%s, key_thumb=%s, key_card=%s, key_detail=%s WHERE id=%s",
|
||||
(keys["original"], keys["thumb"], keys["card"], keys["detail"], iid))
|
||||
conn.commit()
|
||||
if with_detail_bytes is not None and store is not None:
|
||||
store.put(keys["detail"], with_detail_bytes, "image/webp")
|
||||
return sf, iid, keys
|
||||
|
||||
|
||||
def test_serves_fetched_rendition_with_immutable_cache(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
_sf, iid, _keys = _seed_image(fresh_db_url, status="fetched",
|
||||
with_detail_bytes=b"WEBPDATA", store=client.app.state.objectstore)
|
||||
resp = client.get(f"/api/products/images/{iid}/detail")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "image/webp"
|
||||
assert "immutable" in resp.headers.get("cache-control", "")
|
||||
assert resp.content == b"WEBPDATA"
|
||||
|
||||
|
||||
def test_not_fetched_returns_409(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
_sf, iid, _keys = _seed_image(fresh_db_url, status="pending")
|
||||
resp = client.get(f"/api/products/images/{iid}/detail")
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "not_fetched"
|
||||
|
||||
|
||||
def test_other_storefronts_image_is_404(fresh_db_url):
|
||||
# Sign in as merchant B first (this migrates the DB), then seed an image
|
||||
# belonging to a *different* storefront A — it must be invisible to B.
|
||||
with _merchant_client(fresh_db_url, email="b@example.com") as client:
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
a = conn.execute("INSERT INTO account (email) VALUES ('a@example.com') RETURNING id").fetchone()[0]
|
||||
sfa = conn.execute("INSERT INTO storefront (name) VALUES ('A') RETURNING id").fetchone()[0]
|
||||
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (a, sfa))
|
||||
pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id", (sfa,)).fetchone()[0]
|
||||
iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status) VALUES (%s,'u',1,'fetched') RETURNING id", (pid,)).fetchone()[0]
|
||||
conn.commit()
|
||||
resp = client.get(f"/api/products/images/{iid}/detail")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_unauthenticated_is_401(fresh_db_url):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
assert client.get("/api/products/images/1/detail").status_code == 401
|
||||
|
||||
|
||||
def test_bad_rendition_is_422(fresh_db_url):
|
||||
with _merchant_client(fresh_db_url) as client:
|
||||
_sf, iid, _keys = _seed_image(fresh_db_url, status="fetched",
|
||||
with_detail_bytes=b"x", store=client.app.state.objectstore)
|
||||
assert client.get(f"/api/products/images/{iid}/huge").status_code == 422
|
||||
@@ -1,159 +0,0 @@
|
||||
"""image-fetch phase: SSRF guard, fetch+process+store, run completion, resume (§6.5.4/§6.9)."""
|
||||
import io
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from app.domains.products import imagefetch, repo
|
||||
from app.platform import db, objectstore
|
||||
|
||||
|
||||
def _png(w, h):
|
||||
buf = io.BytesIO(); Image.new("RGB", (w, h), (1, 2, 3)).save(buf, "PNG"); return buf.getvalue()
|
||||
|
||||
|
||||
class _Host(BaseHTTPRequestHandler):
|
||||
GOOD = _png(800, 800)
|
||||
TINY = _png(64, 64)
|
||||
def log_message(self, *a): pass
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/good.png"):
|
||||
self.send_response(200); self.send_header("Content-Type", "image/png")
|
||||
self.end_headers(); self.wfile.write(self.GOOD)
|
||||
elif self.path.startswith("/tiny.png"):
|
||||
self.send_response(200); self.send_header("Content-Type", "image/png")
|
||||
self.end_headers(); self.wfile.write(self.TINY)
|
||||
else:
|
||||
self.send_response(404); self.end_headers()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def host():
|
||||
srv = HTTPServer(("127.0.0.1", 0), _Host)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True); t.start()
|
||||
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
||||
srv.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def pool(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn); conn.commit()
|
||||
p = db.open_pool(fresh_db_url, max_size=6)
|
||||
yield p
|
||||
p.close()
|
||||
|
||||
|
||||
def _seed(pool):
|
||||
"""account+storefront+run; returns (storefront_id, account_id, run_id)."""
|
||||
with pool.connection() as conn:
|
||||
acct = conn.execute("INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||
sf = conn.execute("INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||
rid = conn.execute(
|
||||
"INSERT INTO import_run (storefront_id, account_id, file_name, dialect,"
|
||||
" products_added, products_updated, rows_errored, status)"
|
||||
" VALUES (%s,%s,'c.csv','canonical',0,0,0,'fetching_images') RETURNING id", (sf, acct)).fetchone()[0]
|
||||
conn.commit()
|
||||
return sf, acct, rid
|
||||
|
||||
|
||||
def _product(pool, sf, handle):
|
||||
with pool.connection() as conn:
|
||||
pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id",
|
||||
(sf, handle, handle.title())).fetchone()[0]
|
||||
conn.commit()
|
||||
return pid
|
||||
|
||||
|
||||
def _image(pool, pid, url, rid, position=1):
|
||||
with pool.connection() as conn:
|
||||
iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status, import_run_id)"
|
||||
" VALUES (%s,%s,%s,'pending',%s) RETURNING id", (pid, url, position, rid)).fetchone()[0]
|
||||
conn.commit()
|
||||
return iid
|
||||
|
||||
|
||||
def _img_row(pool, iid):
|
||||
with pool.connection() as conn:
|
||||
r = conn.execute("SELECT status, key_original, key_thumb, key_card, key_detail FROM product_image WHERE id=%s",
|
||||
(iid,)).fetchone()
|
||||
return {"status": r[0], "key_original": r[1], "key_thumb": r[2], "key_card": r[3], "key_detail": r[4]}
|
||||
|
||||
|
||||
def _run_status(pool, rid):
|
||||
with pool.connection() as conn:
|
||||
return conn.execute("SELECT status FROM import_run WHERE id=%s", (rid,)).fetchone()[0]
|
||||
|
||||
|
||||
def test_ssrf_guard_rejects_private_when_disallowed():
|
||||
with pytest.raises(imagefetch.FetchBlocked):
|
||||
imagefetch.fetch_bytes("http://127.0.0.1:1/x.png", allow_private=False)
|
||||
with pytest.raises(imagefetch.FetchBlocked):
|
||||
imagefetch.fetch_bytes("http://169.254.169.254/latest/meta-data", allow_private=False)
|
||||
with pytest.raises(imagefetch.FetchBlocked):
|
||||
imagefetch.fetch_bytes("ftp://example.com/x", allow_private=False)
|
||||
|
||||
|
||||
def test_ssrf_guard_allows_private_when_enabled(host):
|
||||
data, ctype = imagefetch.fetch_bytes(f"{host}/good.png", allow_private=True)
|
||||
assert ctype.startswith("image/") and len(data) > 0
|
||||
|
||||
|
||||
def test_run_phase_classifies_each_image(pool, host, tmp_path):
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
sf, _acct, rid = _seed(pool)
|
||||
p = _product(pool, sf, "lamp")
|
||||
ok = _image(pool, p, f"{host}/good.png", rid, position=1)
|
||||
_image(pool, p, f"{host}/tiny.png", rid, position=2)
|
||||
_image(pool, p, f"{host}/missing.png", rid, position=3)
|
||||
imagefetch.run_image_phase(pool, store, rid, allow_private=True)
|
||||
with pool.connection() as conn:
|
||||
counts = repo.run_image_counts(conn, rid)
|
||||
assert counts["fetched"] == 1 and counts["rejected"] == 1 and counts["failed"] == 1
|
||||
row = _img_row(pool, ok)
|
||||
assert row["status"] == "fetched"
|
||||
for k in ("key_original", "key_thumb", "key_card", "key_detail"):
|
||||
assert row[k] and store.get(row[k])
|
||||
assert _run_status(pool, rid) == "complete_with_problems"
|
||||
|
||||
|
||||
def test_unexpected_processing_error_marks_image_failed_not_wedged(pool, host, tmp_path, monkeypatch):
|
||||
# If anything after the fetch raises unexpectedly (e.g. objectstore.put),
|
||||
# the image is marked failed and the run still reaches a terminal status —
|
||||
# never stranded in fetching_images.
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
sf, _acct, rid = _seed(pool)
|
||||
p = _product(pool, sf, "lamp")
|
||||
iid = _image(pool, p, f"{host}/good.png", rid, position=1)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("storage down")
|
||||
monkeypatch.setattr(store, "put", _boom)
|
||||
|
||||
imagefetch.run_image_phase(pool, store, rid, allow_private=True)
|
||||
with pool.connection() as conn:
|
||||
counts = repo.run_image_counts(conn, rid)
|
||||
assert counts["failed"] == 1 and counts["pending"] == 0
|
||||
assert _run_status(pool, rid) == "complete_with_problems"
|
||||
row = _img_row(pool, iid)
|
||||
assert row["status"] == "failed"
|
||||
|
||||
|
||||
def test_resume_after_kill_completes_remaining(pool, host, tmp_path):
|
||||
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||
sf, _acct, rid = _seed(pool)
|
||||
p = _product(pool, sf, "lamp")
|
||||
a = _image(pool, p, f"{host}/good.png", rid, position=1)
|
||||
with pool.connection() as conn: # simulate crash: one already fetched, run still fetching_images
|
||||
repo.mark_image_fetched(conn, a, {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
|
||||
conn.commit()
|
||||
_image(pool, p, f"{host}/good.png?2", rid, position=2) # still pending
|
||||
resumed = imagefetch.recover_incomplete_runs(pool, store, allow_private=True)
|
||||
assert rid in resumed
|
||||
with pool.connection() as conn:
|
||||
assert repo.run_image_counts(conn, rid)["pending"] == 0
|
||||
assert _run_status(pool, rid) == "complete"
|
||||
@@ -1,101 +0,0 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# A partial Shopify export (signature: Body (HTML) + Variant Grams) naming only one
|
||||
# of the two existing products — INV-10 must hold across the dialect boundary.
|
||||
SHOPIFY_PARTIAL = (
|
||||
b"Handle,Title,Body (HTML),Variant Price,Variant Grams\n"
|
||||
b"mug,Mug,<p>x</p>,12.00,180\n"
|
||||
)
|
||||
|
||||
|
||||
def test_inv10_shopify_partial_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]
|
||||
d = products.import_validate(migrated_conn, sf, acct, "shopify.csv", SHOPIFY_PARTIAL)
|
||||
assert d["dialect"] == "shopify"
|
||||
products.confirm_draft(migrated_conn, sf, acct, d["id"])
|
||||
after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||
# INV-10: nothing deleted; the unmentioned 'tee' is untouched.
|
||||
assert after >= before == 2
|
||||
handles = {r[0] for r in migrated_conn.execute("SELECT handle FROM product").fetchall()}
|
||||
assert {"mug", "tee"} <= handles
|
||||
@@ -1,93 +0,0 @@
|
||||
"""products repo — image-phase helpers (SD-0002 §6.5.4)."""
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains.products import repo
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@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 _run(conn, m, status="fetching_images"):
|
||||
return conn.execute(
|
||||
"INSERT INTO import_run (storefront_id, account_id, file_name, dialect,"
|
||||
" products_added, products_updated, rows_errored, status)"
|
||||
" VALUES (%s,%s,'c.csv','canonical',0,0,0,%s) RETURNING id",
|
||||
(m["storefront_id"], m["account_id"], status)).fetchone()[0]
|
||||
|
||||
|
||||
def _product(conn, m, handle):
|
||||
return conn.execute(
|
||||
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id",
|
||||
(m["storefront_id"], handle, handle.title())).fetchone()[0]
|
||||
|
||||
|
||||
def _image(conn, product_id, url, run_id, status="pending", position=1):
|
||||
return conn.execute(
|
||||
"INSERT INTO product_image (product_id, source_url, position, status, import_run_id)"
|
||||
" VALUES (%s,%s,%s,%s,%s) RETURNING id",
|
||||
(product_id, url, position, status, run_id)).fetchone()[0]
|
||||
|
||||
|
||||
def test_pending_images_for_run_returns_only_pending(migrated_conn, merchant):
|
||||
rid = _run(migrated_conn, merchant)
|
||||
p = _product(migrated_conn, merchant, "lamp")
|
||||
a = _image(migrated_conn, p, "https://m/a.png", rid, status="pending", position=1)
|
||||
_image(migrated_conn, p, "https://m/b.png", rid, status="fetched", position=2)
|
||||
pend = repo.pending_images_for_run(migrated_conn, rid)
|
||||
assert [img["id"] for img in pend] == [a]
|
||||
assert pend[0]["source_url"] == "https://m/a.png"
|
||||
assert pend[0]["storefront_id"] == merchant["storefront_id"]
|
||||
|
||||
|
||||
def test_claim_image_for_fetch_is_idempotent(migrated_conn, merchant):
|
||||
rid = _run(migrated_conn, merchant)
|
||||
p = _product(migrated_conn, merchant, "lamp")
|
||||
img = _image(migrated_conn, p, "https://m/a.png", rid)
|
||||
assert repo.claim_image_for_fetch(migrated_conn, img) is True
|
||||
repo.mark_image_fetched(migrated_conn, img,
|
||||
{"original": "o", "thumb": "t", "card": "c", "detail": "d"})
|
||||
assert repo.claim_image_for_fetch(migrated_conn, img) is False
|
||||
|
||||
|
||||
def test_mark_image_outcomes_and_run_counts(migrated_conn, merchant):
|
||||
rid = _run(migrated_conn, merchant)
|
||||
p = _product(migrated_conn, merchant, "lamp")
|
||||
ok = _image(migrated_conn, p, "https://m/a.png", rid, position=1)
|
||||
bad = _image(migrated_conn, p, "https://m/b.png", rid, position=2)
|
||||
miss = _image(migrated_conn, p, "https://m/c.png", rid, position=3)
|
||||
repo.mark_image_fetched(migrated_conn, ok, {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
|
||||
repo.mark_image_rejected(migrated_conn, bad, "rejected_low_res", "below the resolution bar")
|
||||
repo.mark_image_failed(migrated_conn, miss, "host unreachable")
|
||||
assert repo.run_image_counts(migrated_conn, rid) == {
|
||||
"fetched": 1, "rejected": 1, "failed": 1, "pending": 0, "total": 3}
|
||||
outcomes = repo.run_image_outcomes(migrated_conn, rid)
|
||||
assert {o["handle"] for o in outcomes} == {"lamp"}
|
||||
assert {o["outcome"] for o in outcomes} == {"rejected_low_res", "failed"} # fetched not listed
|
||||
|
||||
|
||||
def test_set_run_status_and_incomplete_runs(migrated_conn, merchant):
|
||||
rid = _run(migrated_conn, merchant, status="fetching_images")
|
||||
_run(migrated_conn, merchant, status="complete")
|
||||
assert rid in [r["id"] for r in repo.incomplete_runs(migrated_conn)]
|
||||
repo.set_run_status(migrated_conn, rid, "complete")
|
||||
assert rid not in [r["id"] for r in repo.incomplete_runs(migrated_conn)]
|
||||
row = migrated_conn.execute("SELECT status, completed_at FROM import_run WHERE id=%s", (rid,)).fetchone()
|
||||
assert row[0] == "complete" and row[1] is not None
|
||||
@@ -1,242 +0,0 @@
|
||||
"""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)
|
||||
|
||||
|
||||
def test_confirm_marks_run_fetching_images_when_images_present(migrated_conn, merchant):
|
||||
csv = b"Handle,Title,Image Src\nlamp,Lamp,https://m.example/a.png\n"
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
|
||||
run_id = products.confirm_draft(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||
assert run["status"] == "fetching_images"
|
||||
assert run["image_progress"]["total"] >= 1
|
||||
|
||||
|
||||
def test_confirm_marks_run_complete_when_no_images(migrated_conn, merchant):
|
||||
csv = b"Handle,Title,Vendor\nlamp,Lamp,Acme\n"
|
||||
draft = products.import_validate(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
|
||||
run_id = products.confirm_draft(
|
||||
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||
assert products.get_run(migrated_conn, merchant["storefront_id"], run_id)["status"] == "complete"
|
||||
@@ -1,24 +0,0 @@
|
||||
"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so
|
||||
the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite
|
||||
serves the frontend and the default dist dir simply doesn't exist."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_spa_served_when_dist_present(fresh_db_url, tmp_path):
|
||||
(tmp_path / "index.html").write_text("<!doctype html><title>ecomm spa</title>")
|
||||
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path)) as client:
|
||||
root = client.get("/")
|
||||
assert root.status_code == 200
|
||||
assert "ecomm spa" in root.text
|
||||
# API + health still win over the mount
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
|
||||
|
||||
def test_no_mount_when_dist_absent(fresh_db_url, tmp_path):
|
||||
# An empty/missing dist (the dev case) must not 500 the app — / just 404s.
|
||||
with TestClient(create_app(database_url=fresh_db_url, static_dir=tmp_path / "nope")) as client:
|
||||
assert client.get("/").status_code == 404
|
||||
assert client.get("/healthz").json()["status"] == "ok"
|
||||
@@ -1,154 +0,0 @@
|
||||
"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7)."""
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
from app.domains import storefronts
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_conn(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
yield conn
|
||||
|
||||
|
||||
def _account_id(conn, email="merchant@example.com"):
|
||||
return conn.execute(
|
||||
"INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)
|
||||
).fetchone()[0]
|
||||
|
||||
|
||||
def test_create_storefront_with_name(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets")
|
||||
assert sf.name == "Ben's Bets"
|
||||
assert storefronts.storefront_for(migrated_conn, acct) == sf
|
||||
|
||||
|
||||
def test_14_01_0026_blank_name_gets_generated_default(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||
assert sf.name == "merchant's storefront"
|
||||
|
||||
|
||||
def test_whitespace_name_treated_as_blank(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ")
|
||||
assert sf.name == "ben's storefront"
|
||||
|
||||
|
||||
def test_second_create_refused_inv_4(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First")
|
||||
with pytest.raises(storefronts.AlreadyOwnsStorefront):
|
||||
storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second")
|
||||
|
||||
|
||||
def test_membership_row_is_owner(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None)
|
||||
role = migrated_conn.execute(
|
||||
"SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s",
|
||||
(acct, sf.id),
|
||||
).fetchone()[0]
|
||||
assert role == "owner"
|
||||
|
||||
|
||||
def test_storefront_for_none_when_absent(migrated_conn):
|
||||
acct = _account_id(migrated_conn)
|
||||
assert storefronts.storefront_for(migrated_conn, acct) is None
|
||||
|
||||
|
||||
# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ──────────
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _signed_in_client(fresh_db_url, email="merchant@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})
|
||||
yield client
|
||||
|
||||
|
||||
def test_14_01_0013_create_storefront_with_name(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
resp = client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["name"] == "Ben's Bets"
|
||||
assert isinstance(resp.json()["id"], int)
|
||||
|
||||
|
||||
def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url):
|
||||
# No payment method, plan, or commitment: the request body needs nothing at all and
|
||||
# the response carries only the storefront — no plan/trial/billing fields.
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
resp = client.post("/api/storefronts", json={})
|
||||
assert resp.status_code == 201
|
||||
assert set(resp.json().keys()) == {"id", "name"}
|
||||
|
||||
|
||||
def test_14_01_0025_create_lands_on_admin(fresh_db_url):
|
||||
# After create, the entry-routing answer carries the storefront -> admin (PUC-6).
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json()
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["storefront"] == created
|
||||
|
||||
|
||||
def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["storefront"] is None
|
||||
|
||||
|
||||
def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url):
|
||||
# PUC-6: a returning merchant's verify response already carries the storefront.
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
email = "returning@example.com"
|
||||
with _signed_in_client(fresh_db_url, email) as client:
|
||||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
# fresh login: backdate the consumed code to clear the 60s resend cooldown
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), email),
|
||||
)
|
||||
c.commit()
|
||||
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)
|
||||
resp = client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["storefront"]["name"] == "Ben's Bets"
|
||||
assert resp.json()["created"] is False
|
||||
|
||||
|
||||
def test_puc_07_second_create_refused_409(fresh_db_url):
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
client.post("/api/storefronts", json={"name": "First"})
|
||||
resp = client.post("/api/storefronts", json={"name": "Second"})
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["error"]["code"] == "already_owns_storefront"
|
||||
|
||||
|
||||
def test_puc_08_admin_shell_answer(fresh_db_url):
|
||||
# The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8).
|
||||
with _signed_in_client(fresh_db_url) as client:
|
||||
client.post("/api/storefronts", json={"name": "Ben's Bets"})
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["account"]["email"] == "merchant@example.com"
|
||||
assert me["storefront"]["name"] == "Ben's Bets"
|
||||
|
||||
|
||||
def test_create_storefront_requires_session(fresh_db_url):
|
||||
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||
resp = client.post("/api/storefronts", json={"name": "Nope"})
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
@@ -1,64 +0,0 @@
|
||||
"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the
|
||||
membership schema staying many-capable (the rule is a deletable guard, not a schema law)."""
|
||||
import threading
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
|
||||
from app.domains import storefronts
|
||||
from app.platform import db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrated_url(fresh_db_url):
|
||||
with psycopg.connect(fresh_db_url) as conn:
|
||||
db.migrate(conn)
|
||||
return fresh_db_url
|
||||
|
||||
|
||||
def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url):
|
||||
with psycopg.connect(migrated_url) as setup:
|
||||
acct = setup.execute(
|
||||
"INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id"
|
||||
).fetchone()[0]
|
||||
setup.commit()
|
||||
|
||||
barrier = threading.Barrier(2)
|
||||
results: list[str] = []
|
||||
|
||||
def racer():
|
||||
with psycopg.connect(migrated_url) as conn:
|
||||
barrier.wait()
|
||||
try:
|
||||
storefronts.create_storefront(conn, acct, "racer@example.com", None)
|
||||
results.append("created")
|
||||
except storefronts.AlreadyOwnsStorefront:
|
||||
results.append("refused")
|
||||
|
||||
threads = [threading.Thread(target=racer) for _ in range(2)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert sorted(results) == ["created", "refused"]
|
||||
with psycopg.connect(migrated_url) as check:
|
||||
count = check.execute(
|
||||
"SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,)
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_inv_4_schema_stays_many_capable(migrated_url):
|
||||
# No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on
|
||||
# storefront_membership is its composite PK, so many-per-account stays a deletable
|
||||
# service rule (R-5 mitigation).
|
||||
with psycopg.connect(migrated_url) as conn:
|
||||
uniques = conn.execute(
|
||||
"SELECT i.indkey::text FROM pg_index i"
|
||||
" JOIN pg_class c ON c.oid = i.indrelid"
|
||||
" WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)"
|
||||
).fetchall()
|
||||
# exactly one unique index (the composite PK over two columns)
|
||||
assert len(uniques) == 1
|
||||
assert len(uniques[0][0].split()) == 2
|
||||
+3
-2
@@ -35,8 +35,9 @@ ECOMM_SMTP_HOST = "smtp.gmail.com"
|
||||
ECOMM_SMTP_PORT = "587"
|
||||
ECOMM_SMTP_USER = "ben.stull@wiggleverse.org"
|
||||
ECOMM_SMTP_FROM = "ecomm <ben.stull@wiggleverse.org>"
|
||||
ECOMM_OBJECTSTORE_KIND = "gcs"
|
||||
ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media"
|
||||
# Objectstore (GCS) env removed in the network-seed reset — the seed no longer reads it.
|
||||
# Re-add when network catalog-image storage lands; bucket wiggleverse-ecomm-ppe-media stays
|
||||
# provisioned.
|
||||
|
||||
[secrets] # REFERENCES only — never bytes (§8.3)
|
||||
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
node_modules/
|
||||
.backend.log
|
||||
.objectstore/
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -1,4 +0,0 @@
|
||||
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
|
||||
|
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 184 B |
@@ -1,2 +0,0 @@
|
||||
Handle,Vendor
|
||||
mug,Acme
|
||||
|
@@ -1,4 +0,0 @@
|
||||
Handle,Title,Variant Price
|
||||
good-mug,Good Mug,10.00
|
||||
bad-tee,Bad Tee,not-a-price
|
||||
also-good,Also Good,5.00
|
||||
|
@@ -1,2 +0,0 @@
|
||||
Handle,Title,Body (HTML),Vendor,Type,Tags,Published,Status,Variant SKU,Variant Grams,Variant Inventory Qty,Variant Price,Variant Compare At Price,Gift Card,SEO Title,Cost per item
|
||||
shopify-mug,Shopify Mug,<p>Imported from Shopify.</p>,Acme,Drinkware,"mugs",TRUE,active,SM-001,300,25,14.00,20.00,FALSE,Shopify Mug | Acme,7.00
|
||||
|
@@ -1,4 +0,0 @@
|
||||
Handle,Title,Image Src
|
||||
good-lamp,Good Lamp,http://127.0.0.1:8799/good.png
|
||||
tiny-lamp,Tiny Lamp,http://127.0.0.1:8799/tiny.png
|
||||
gone-lamp,Gone Lamp,http://127.0.0.1:8799/missing.png
|
||||
|
-120
@@ -1,120 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Import with-images.csv (3 products, each with one Image Src) and confirm it.
|
||||
// Mirrors importGoodCsv; callers continue from the run-detail screen, where the
|
||||
// background image fetch progresses fetching_images → terminal.
|
||||
export async function importWithImages(page: Page) {
|
||||
await uploadFixture(page, "with-images.csv");
|
||||
await expect(page.getByRole("heading", { name: "Import preview — with-images.csv" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Import 3 products" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "with-images.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 };
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const dir = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "images");
|
||||
createServer(async (req, res) => {
|
||||
const name = (req.url || "").replace(/^\//, "").split("?")[0];
|
||||
if (name === "good.png" || name === "tiny.png") {
|
||||
try {
|
||||
const buf = await readFile(join(dir, name));
|
||||
res.writeHead(200, { "Content-Type": "image/png" });
|
||||
res.end(buf);
|
||||
return;
|
||||
} catch { /* fallthrough */ }
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}).listen(8799, "127.0.0.1");
|
||||
Generated
-76
@@ -1,76 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-e2e",
|
||||
"private": true,
|
||||
"scripts": { "test": "playwright test" },
|
||||
"devDependencies": { "@playwright/test": "^1.48.0" }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# Image pipeline (SLICE-7): local objectstore + a fixture image host on :8799.
|
||||
# ECOMM_IMAGE_FETCH_ALLOW_PRIVATE lets the SSRF guard reach 127.0.0.1 — TEST-ONLY,
|
||||
# never set in the deployed overlay. The &-backgrounded node host is a child of
|
||||
# this serve process; Playwright tears the whole webServer group down between runs.
|
||||
export ECOMM_OBJECTSTORE_KIND=local
|
||||
export ECOMM_OBJECTSTORE_DIR="$repo_root/e2e/.objectstore"
|
||||
export ECOMM_IMAGE_FETCH_ALLOW_PRIVATE=1
|
||||
rm -rf "$repo_root/e2e/.objectstore"
|
||||
node "$repo_root/e2e/image-host.mjs" &
|
||||
|
||||
cd "$repo_root/backend"
|
||||
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
|
||||
> "$repo_root/e2e/.backend.log" 2>&1
|
||||
@@ -1,21 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, importWithImages, signUpWithStorefront } from "../helpers";
|
||||
|
||||
test("e2e_image_outcomes: per-image outcomes, problem rows, notice band", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
await importWithImages(page);
|
||||
// Run detail polls fetching_images → terminal; wait for the outcome summary.
|
||||
await expect(page.getByText("1 fetched · 1 rejected · 1 failed")).toBeVisible({ timeout: 30_000 });
|
||||
// Problem images listed in the outcomes table.
|
||||
await expect(page.getByText("tiny-lamp")).toBeVisible();
|
||||
await expect(page.getByText("gone-lamp")).toBeVisible();
|
||||
// Products page surfaces the aggregate notice.
|
||||
await gotoProducts(page);
|
||||
await expect(page.getByText(/products have image problems/)).toBeVisible();
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
// DoD scenario e2e_import_shopify_dialect (SD-0002 §6.8, SLICE-8): a Shopify
|
||||
// product CSV imports directly (PUC-6). Upload an unmodified-shape Shopify export;
|
||||
// the preview recognizes the dialect ("Shopify product CSV — mapped"), lists the
|
||||
// columns with no canonical home as not-imported, and the import confirms and
|
||||
// completes — the run report card carrying the same dialect label.
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||
|
||||
test("e2e_import_shopify_dialect", async ({ page }) => {
|
||||
await signUpWithStorefront(page);
|
||||
await gotoProducts(page);
|
||||
|
||||
await uploadFixture(page, "shopify-export.csv");
|
||||
|
||||
// Preview (§5.4): the file name, the recognized dialect, and the not-imported band.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Import preview — shopify-export.csv" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
|
||||
// The not-imported band lists Shopify columns with no canonical home. Assert a
|
||||
// column that never appears in canonical diff detail, so the match is unambiguous.
|
||||
await expect(page.getByText("Columns not imported")).toBeVisible();
|
||||
await expect(page.getByText("Variant Compare At Price")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "1 to add" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
|
||||
|
||||
// Consent gate (PUC-3): confirm the import.
|
||||
await page.getByRole("button", { name: "Import 1 products" }).click();
|
||||
|
||||
// Run detail (§5.5): report card carries the same dialect label and completes.
|
||||
await expect(page.getByRole("heading", { level: 1, name: "shopify-export.csv" })).toBeVisible();
|
||||
await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible();
|
||||
await expect(page.getByText("1 added · 0 updated · 0 rows in error")).toBeVisible();
|
||||
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0E1230" />
|
||||
<style>html { background: #0E1230; }</style>
|
||||
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||||
<title>ecomm</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-2174
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"name": "wiggleverse-ecomm-frontend",
|
||||
"private": true,
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse — mono gold</title>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="2.4" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#F4C76B">
|
||||
<circle cx="98" cy="60" r="4.5"></circle><circle cx="79" cy="93" r="4.5"></circle><circle cx="41" cy="93" r="4.5"></circle>
|
||||
<circle cx="22" cy="60" r="4.5"></circle><circle cx="41" cy="27" r="4.5"></circle><circle cx="79" cy="27" r="4.5"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 648 B |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none" role="img" aria-label="Wiggleverse">
|
||||
<title>Wiggleverse</title>
|
||||
<rect width="120" height="120" rx="26" fill="#0E1230"></rect>
|
||||
<path d="M98 60 Q105 86 79 93 Q60 82 41 93 Q15 86 22 60 Q41 49 41 27 Q60 8 79 27 Q79 49 98 60 Z" stroke="#F4C76B" stroke-width="5" fill="none" stroke-linejoin="round"></path>
|
||||
<g fill="#EDEAFF">
|
||||
<circle cx="98" cy="60" r="7"></circle><circle cx="79" cy="93" r="7"></circle><circle cx="41" cy="93" r="7"></circle>
|
||||
<circle cx="22" cy="60" r="7"></circle><circle cx="41" cy="27" r="7"></circle><circle cx="79" cy="27" r="7"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 684 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,91 +0,0 @@
|
||||
// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders
|
||||
// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify
|
||||
// response carries the same shape as /me, so onAuthed feeds the session directly.
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMe, type StorefrontResult, type VerifyResult } from "./api";
|
||||
import { routeFor, type SessionState } from "./routing";
|
||||
import Admin from "./screens/Admin";
|
||||
import CreateStorefront from "./screens/CreateStorefront";
|
||||
import Landing from "./screens/Landing";
|
||||
import SignIn from "./screens/SignIn";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
type Welcome = "new" | "back" | null;
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [door, setDoor] = useState<Door | null>(null);
|
||||
const [welcome, setWelcome] = useState<Welcome>(null);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setSession(await getMe());
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="screen screen--plain">
|
||||
<main className="screen__main">
|
||||
<p className="note">Loading…</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const route = routeFor(session ?? { account: null, storefront: null });
|
||||
|
||||
if (route === "landing") {
|
||||
if (door) {
|
||||
return (
|
||||
<SignIn
|
||||
door={door}
|
||||
onBack={() => setDoor(null)}
|
||||
onAuthed={(result: VerifyResult) => {
|
||||
setWelcome(result.created ? "new" : "back");
|
||||
setDoor(null);
|
||||
setSession({ account: result.account, storefront: result.storefront });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Landing onGetStarted={() => setDoor("signup")} onLogIn={() => setDoor("login")} />;
|
||||
}
|
||||
|
||||
const email = session!.account!.email;
|
||||
|
||||
function signedOut() {
|
||||
setWelcome(null);
|
||||
setDoor(null);
|
||||
setSession(null);
|
||||
}
|
||||
|
||||
if (route === "create-storefront") {
|
||||
return (
|
||||
<CreateStorefront
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onCreated={(sf: StorefrontResult) => {
|
||||
setWelcome(null);
|
||||
setSession({ account: { email }, storefront: sf });
|
||||
}}
|
||||
onAlreadyOwns={() => void reload()}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Admin
|
||||
storefrontName={session!.storefront!.name}
|
||||
email={email}
|
||||
welcome={welcome}
|
||||
onSignedOut={signedOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
// 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,79 +0,0 @@
|
||||
// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite
|
||||
// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them.
|
||||
import type { SessionState } from "./routing";
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
retry_after_s?: number;
|
||||
attempts_remaining?: number;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
account: { email: string };
|
||||
storefront: { id: number; name: string } | null;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export async function errorOf(resp: Response): Promise<ApiError> {
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body && body.error) return body.error as ApiError;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return { code: "unexpected", message: "Something went wrong. Please try again." };
|
||||
}
|
||||
|
||||
export async function getMe(): Promise<SessionState | null> {
|
||||
const resp = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (resp.status === 401) return null;
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as SessionState;
|
||||
}
|
||||
|
||||
export async function requestCode(email: string): Promise<ApiError | null> {
|
||||
const resp = await fetch("/api/auth/request-code", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
return resp.ok ? null : await errorOf(resp);
|
||||
}
|
||||
|
||||
export async function verifyCode(
|
||||
email: string,
|
||||
code: string,
|
||||
): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/auth/verify", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
}
|
||||
|
||||
export interface StorefrontResult {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function createStorefront(
|
||||
name: string,
|
||||
): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> {
|
||||
const resp = await fetch("/api/storefronts", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(name.trim() ? { name: name.trim() } : {}),
|
||||
});
|
||||
if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult };
|
||||
return { ok: false, error: await errorOf(resp) };
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cooldownAppliesTo } from "./cooldown";
|
||||
|
||||
describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => {
|
||||
it("no cooldown running -> send not blocked", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("cooldown running for the same address -> blocked", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true);
|
||||
});
|
||||
|
||||
it("same address modulo case/whitespace -> still blocked", () => {
|
||||
expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true);
|
||||
});
|
||||
|
||||
it("cooldown running but the input is a different address -> not blocked", () => {
|
||||
expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false);
|
||||
});
|
||||
|
||||
it("cooldown expired -> not blocked even for the same address", () => {
|
||||
expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is
|
||||
// per-address, so the client countdown must be too: it blocks sending only while it
|
||||
// is running AND the current input is the address it was set for. Normalization
|
||||
// mirrors the backend's normalize_email (lowercase + strip, INV-2).
|
||||
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function cooldownAppliesTo(
|
||||
input: string,
|
||||
cooldownEmail: string | null,
|
||||
secondsLeft: number,
|
||||
): boolean {
|
||||
if (secondsLeft <= 0 || cooldownEmail === null) return false;
|
||||
return normalize(input) === normalize(cooldownEmail);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import "./styles/index.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dialectLabel } from "./productsApi";
|
||||
|
||||
describe("dialectLabel", () => {
|
||||
it("labels canonical", () => expect(dialectLabel("canonical")).toBe("Canonical format"));
|
||||
it("labels shopify as mapped (PUC-6)", () =>
|
||||
expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped"));
|
||||
it("passes through an unknown dialect verbatim", () =>
|
||||
expect(dialectLabel("weird")).toBe("weird"));
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
// 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 ImageOutcome {
|
||||
handle: string; url: string; outcome: string; reason: string | null; variant: string | null;
|
||||
}
|
||||
export interface ImageCounts { fetched: number; rejected: number; failed: number; }
|
||||
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;
|
||||
image_counts?: ImageCounts;
|
||||
}
|
||||
export interface RunDetail extends RunSummary {
|
||||
errors: RowErrorDetail[];
|
||||
image_progress: { done: number; total: number };
|
||||
image_counts: ImageCounts;
|
||||
image_outcomes: ImageOutcome[];
|
||||
}
|
||||
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 {
|
||||
if (d === "canonical") return "Canonical format";
|
||||
if (d === "shopify") return "Shopify product CSV — mapped";
|
||||
return 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,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeFor } from "./routing";
|
||||
|
||||
describe("entry routing (SD-0001 §6.5)", () => {
|
||||
it("no session -> landing", () => {
|
||||
expect(routeFor({ account: null, storefront: null })).toBe("landing");
|
||||
});
|
||||
|
||||
it("session without storefront -> create-storefront", () => {
|
||||
expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe(
|
||||
"create-storefront",
|
||||
);
|
||||
});
|
||||
|
||||
it("session with storefront -> admin", () => {
|
||||
expect(
|
||||
routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }),
|
||||
).toBe("admin");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands
|
||||
// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the
|
||||
// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3
|
||||
// makes "admin" reachable.
|
||||
export interface SessionState {
|
||||
account: { email: string } | null;
|
||||
storefront: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
export type Screen = "landing" | "create-storefront" | "admin";
|
||||
|
||||
export function routeFor(s: SessionState): Screen {
|
||||
if (!s.account) return "landing";
|
||||
if (!s.storefront) return "create-storefront";
|
||||
return "admin";
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// 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;
|
||||
email: string;
|
||||
welcome: "new" | "back" | null;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen plain>
|
||||
<TopBar
|
||||
left={
|
||||
<span className="storeid">
|
||||
<img src="/brand/mark-tile.svg" width={24} height={24} alt="" />
|
||||
<span className="storeid__col">
|
||||
<span className="storeid__name">{storefrontName}</span>
|
||||
<span className="storeid__sub">ecomm storefront</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
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">
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one
|
||||
// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export
|
||||
// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step.
|
||||
import { useState } from "react";
|
||||
import { createStorefront, logout, type StorefrontResult } from "../api";
|
||||
import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
welcome: "new" | "back" | null;
|
||||
onCreated: (storefront: StorefrontResult) => void;
|
||||
onAlreadyOwns: () => void;
|
||||
onSignedOut: () => void;
|
||||
}
|
||||
|
||||
export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [alreadyOwns, setAlreadyOwns] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
onSignedOut();
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await createStorefront(name);
|
||||
setBusy(false);
|
||||
if (res.ok) {
|
||||
onCreated(res.storefront);
|
||||
return;
|
||||
}
|
||||
if (res.error.code === "already_owns_storefront") setAlreadyOwns(true);
|
||||
else setError(res.error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar left={<Wordmark size={22} />} right={<AccountChip email={email} onSignOut={signOut} />} />
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
<form onSubmit={submit} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1 style={{ marginTop: 12 }}>Create your storefront</h1>
|
||||
<p className="card__sub">
|
||||
This is the one thing to do right now. It costs nothing and commits you to nothing.
|
||||
</p>
|
||||
</div>
|
||||
{welcome && (
|
||||
<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>
|
||||
)}
|
||||
{alreadyOwns && (
|
||||
<Banner title="Your account already has its storefront">
|
||||
ecomm is one storefront per account today.{" "}
|
||||
<button type="button" className="lk" onClick={onAlreadyOwns}>
|
||||
Go to your admin →
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{error && <Banner title="That didn't work">{error} Try again.</Banner>}
|
||||
<Field
|
||||
label="Storefront name"
|
||||
optional
|
||||
helper="You can leave this blank — we'll pick a placeholder name you can change any time."
|
||||
inputProps={{
|
||||
type: "text",
|
||||
value: name,
|
||||
placeholder: "e.g. Ben's Bets",
|
||||
autoFocus: true,
|
||||
onChange: (ev) => setName(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Creating…" : "Create storefront"}</PrimaryButton>
|
||||
<p className="note">You can rename, configure, or close your storefront whenever you like.</p>
|
||||
</div>
|
||||
</form>
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Landing (SD-0001 §5.1) — a Visitor learns what ecomm is and picks a door. Honest voice,
|
||||
// no trial/pricing/fee (corpus 14.01.0011). Both doors open the same sign-in flow (§5.2);
|
||||
// they differ only in framing. Visuals per the ui/designs export (hf-landing, Direction A).
|
||||
import { Eyebrow, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
interface Props {
|
||||
onGetStarted: () => void;
|
||||
onLogIn: () => void;
|
||||
}
|
||||
|
||||
const PROMISES: Array<[string, string]> = [
|
||||
["No trial clock", "Your storefront never expires or locks."],
|
||||
["No plan wall", "We take only what it takes to run."],
|
||||
["Your data, yours", "Nothing collected you didn't agree to give."],
|
||||
];
|
||||
|
||||
export default function Landing({ onGetStarted, onLogIn }: Props) {
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark />}
|
||||
right={
|
||||
<button type="button" className="lk lk--nav" onClick={onLogIn}>
|
||||
Log in
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<div className="hero">
|
||||
<Eyebrow>One storefront, fully yours</Eyebrow>
|
||||
<h1>
|
||||
Sell online,
|
||||
<br />
|
||||
<em>honestly.</em>
|
||||
</h1>
|
||||
<p className="hero__lead">
|
||||
Claim the one storefront that's yours on a platform that takes only what it takes
|
||||
to run — no trial countdown, no plan wall, no data you didn't agree to give.
|
||||
</p>
|
||||
<div className="hero__actions">
|
||||
<PrimaryButton type="button" onClick={onGetStarted}>
|
||||
Create your storefront →
|
||||
</PrimaryButton>
|
||||
<button type="button" className="lk lk--mute" onClick={onLogIn}>
|
||||
Already selling with us? <span style={{ color: "var(--wv-lilac)" }}>Log in</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="promises">
|
||||
{PROMISES.map(([title, copy]) => (
|
||||
<div key={title}>
|
||||
<div className="promises__title">{title}</div>
|
||||
<p>{copy}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// Sign in (SD-0001 §5.2) — the single email + one-time-code flow behind both doors. Step 1
|
||||
// requests a code; step 2 verifies it. Honest copy for every §5.2 state; storefront routing
|
||||
// is handled by App on success. Visuals per the ui/designs export (hf-signin).
|
||||
import { useEffect, useState } from "react";
|
||||
import { requestCode, verifyCode, type ApiError, type VerifyResult } from "../api";
|
||||
import { cooldownAppliesTo } from "../cooldown";
|
||||
import CodeInput from "../ui/CodeInput";
|
||||
import { AuthCard, Banner, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit";
|
||||
|
||||
type Door = "signup" | "login";
|
||||
|
||||
interface Props {
|
||||
door: Door;
|
||||
onAuthed: (result: VerifyResult) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function useCooldown(): [number, (s: number) => void] {
|
||||
const [left, setLeft] = useState(0);
|
||||
const ticking = left > 0;
|
||||
useEffect(() => {
|
||||
if (!ticking) return;
|
||||
const t = setInterval(() => setLeft((v) => Math.max(0, v - 1)), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [ticking]);
|
||||
return [left, setLeft];
|
||||
}
|
||||
|
||||
export default function SignIn({ door, onAuthed, onBack }: Props) {
|
||||
const [step, setStep] = useState<"email" | "code">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [bannerError, setBannerError] = useState<ApiError | null>(null);
|
||||
const [cooldown, setCooldown] = useCooldown();
|
||||
// The address the running cooldown belongs to — the server's cooldown is per-address
|
||||
// (INV-3), so a different address must not be blocked by it (bug #20).
|
||||
const [cooldownFor, setCooldownFor] = useState<string | null>(null);
|
||||
|
||||
function applyError(err: ApiError) {
|
||||
setFieldError(null);
|
||||
setCodeError(null);
|
||||
setBannerError(null);
|
||||
if (err.code === "invalid_email") setFieldError(err.message);
|
||||
else if (err.code === "resend_cooldown") setCooldown(err.retry_after_s ?? 60);
|
||||
else if (err.code === "code_mismatch" || err.code === "code_expired") setCodeError(err.message);
|
||||
else setBannerError(err); // delivery_failed, code_exhausted, unexpected
|
||||
}
|
||||
|
||||
async function sendCode(): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setFieldError(null);
|
||||
setBannerError(null);
|
||||
const err = await requestCode(email);
|
||||
setBusy(false);
|
||||
if (err) {
|
||||
applyError(err);
|
||||
if (err.code === "resend_cooldown") setCooldownFor(email);
|
||||
return err.code === "resend_cooldown"; // a cooldown still means a code is out there
|
||||
}
|
||||
setCooldown(60);
|
||||
setCooldownFor(email);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submitEmail(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (await sendCode()) {
|
||||
setCode("");
|
||||
setCodeError(null);
|
||||
setStep("code");
|
||||
}
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
setCodeError(null);
|
||||
setCode("");
|
||||
await sendCode();
|
||||
}
|
||||
|
||||
async function submitCode(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setCodeError(null);
|
||||
setBannerError(null);
|
||||
const res = await verifyCode(email, code);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
applyError(res.error);
|
||||
return;
|
||||
}
|
||||
onAuthed(res.result);
|
||||
}
|
||||
|
||||
const heading = door === "signup" ? "Create your storefront" : "Log in";
|
||||
const sub =
|
||||
door === "signup"
|
||||
? "Enter your email to begin. There's no password to create."
|
||||
: "Enter your email and we'll send a one-time code to sign in.";
|
||||
|
||||
return (
|
||||
<Screen horizon>
|
||||
<TopBar
|
||||
left={<Wordmark size={22} />}
|
||||
right={
|
||||
<button type="button" className="lk lk--mute" onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<main className="screen__main">
|
||||
<AuthCard>
|
||||
{step === "email" ? (
|
||||
<form onSubmit={submitEmail} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>{heading}</h1>
|
||||
<p className="card__sub">{sub}</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title="We couldn't send the code">
|
||||
The email didn't go out. Try again in a moment — nothing was lost.
|
||||
</Banner>
|
||||
)}
|
||||
<Field
|
||||
label="Email"
|
||||
error={fieldError}
|
||||
inputProps={{
|
||||
type: "email",
|
||||
value: email,
|
||||
placeholder: "you@example.com",
|
||||
autoFocus: true,
|
||||
required: true,
|
||||
autoComplete: "email",
|
||||
onChange: (ev) => setEmail(ev.target.value),
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy} disabled={cooldownAppliesTo(email, cooldownFor, cooldown)}>
|
||||
{busy
|
||||
? "Sending…"
|
||||
: cooldownAppliesTo(email, cooldownFor, cooldown)
|
||||
? `Resend in ${cooldown}s`
|
||||
: "Send code"}
|
||||
</PrimaryButton>
|
||||
<p className="note">
|
||||
We'll email you a one-time code. That's all we need — no password, ever.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={submitCode} style={{ display: "contents" }}>
|
||||
<div>
|
||||
<h1>Check your email</h1>
|
||||
<p className="card__sub">
|
||||
We sent a 6-digit code to <span style={{ color: "var(--wv-starlight)" }}>{email}</span>.{" "}
|
||||
<button type="button" className="lk" onClick={() => setStep("email")}>
|
||||
Wrong address?
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{bannerError && (
|
||||
<Banner title={bannerError.code === "code_exhausted" ? "Too many attempts" : "Something went wrong"}>
|
||||
{bannerError.code === "code_exhausted"
|
||||
? "That code is no longer valid. Request a fresh one to keep going."
|
||||
: bannerError.message}
|
||||
</Banner>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<CodeInput value={code} onChange={setCode} error={!!codeError} />
|
||||
{codeError && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{codeError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<PrimaryButton busy={busy}>{busy ? "Checking…" : "Continue"}</PrimaryButton>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<p className="note">Good for 10 minutes.</p>
|
||||
{cooldown > 0 ? (
|
||||
<span className="note">Resend in {cooldown}s</span>
|
||||
) : (
|
||||
<button type="button" className="lk" onClick={resend}>
|
||||
Resend code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</AuthCard>
|
||||
</main>
|
||||
<Footer />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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 or a Shopify product CSV.{" "}
|
||||
<a href="/api/products/sample.csv" download>
|
||||
Download sample CSV
|
||||
</a>{" "}
|
||||
·{" "}
|
||||
<a href="/api/products/columns.md" target="_blank" rel="noopener">
|
||||
Column reference
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// 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";
|
||||
import { historyImageCell } from "./runImages";
|
||||
|
||||
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>
|
||||
{summary.image_problem_count > 0 && (
|
||||
<div className="notice" aria-live="polite">
|
||||
<a href={`#/products/imports/runs/${summary.latest_run_id}`}>
|
||||
{summary.image_problem_count} products have image problems
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{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>{" "}
|
||||
·{" "}
|
||||
<a href="/api/products/columns.md" target="_blank" rel="noopener">
|
||||
Column reference
|
||||
</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>Images</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>{historyImageCell(r.image_counts)}</td>
|
||||
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run.
|
||||
// The durable record of the import's image-fetch outcome: live progress while
|
||||
// fetching (polled, no websockets — deliberate), then per-image problem rows. PUC-4/5/8.
|
||||
import { useEffect, useState } from "react";
|
||||
import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi";
|
||||
import {
|
||||
imageOutcomeSummary,
|
||||
imageProgressLabel,
|
||||
isRunTerminal,
|
||||
outcomeLabel,
|
||||
} from "./runImages";
|
||||
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]);
|
||||
|
||||
// While the run is still applying / fetching images, poll the endpoint so the
|
||||
// "Fetching images: X of Y" line advances (no websockets — deliberate, §5.5).
|
||||
// The interval is cleared on terminal status and on unmount.
|
||||
useEffect(() => {
|
||||
if (!run || isRunTerminal(run.status)) return;
|
||||
const id = setInterval(() => {
|
||||
void (async () => {
|
||||
const resp = await getRun(runId);
|
||||
if (resp.ok) setRun(resp.value);
|
||||
})();
|
||||
}, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, [run, 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>
|
||||
)}
|
||||
{!isRunTerminal(run.status) ? (
|
||||
<p className="note" aria-live="polite">
|
||||
{imageProgressLabel(run.image_progress)}
|
||||
</p>
|
||||
) : (
|
||||
run.image_progress.total > 0 && (
|
||||
<>
|
||||
<p className="note">{imageOutcomeSummary(run.image_counts)}</p>
|
||||
{run.image_outcomes.length > 0 ? (
|
||||
<table className="errortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Handle</th>
|
||||
<th>Variant</th>
|
||||
<th>Image URL</th>
|
||||
<th>Outcome</th>
|
||||
<th>What to do</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.image_outcomes.map((o, i) => (
|
||||
<tr key={i}>
|
||||
<td>{o.handle}</td>
|
||||
<td>{o.variant ?? "—"}</td>
|
||||
<td>{o.url}</td>
|
||||
<td>{outcomeLabel(o.outcome)}</td>
|
||||
<td>Correct the URL and re-import.</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="note">All images fetched.</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { historyImageCell, imageOutcomeSummary, imageProgressLabel, isRunTerminal, outcomeLabel } from "./runImages";
|
||||
|
||||
describe("runImages helpers", () => {
|
||||
it("progress label", () => {
|
||||
expect(imageProgressLabel({ done: 312, total: 4950 })).toBe("Fetching images: 312 of 4,950");
|
||||
});
|
||||
it("outcome summary", () => {
|
||||
expect(imageOutcomeSummary({ fetched: 4938, rejected: 9, failed: 3 }))
|
||||
.toBe("4,938 fetched · 9 rejected · 3 failed");
|
||||
});
|
||||
it("history cell collapses clean / shows problems / dashes empty", () => {
|
||||
expect(historyImageCell({ fetched: 10, rejected: 0, failed: 0 })).toBe("10 ✓");
|
||||
expect(historyImageCell({ fetched: 8, rejected: 1, failed: 1 })).toBe("8 ✓ · 2 ✗");
|
||||
expect(historyImageCell(undefined)).toBe("—");
|
||||
});
|
||||
it("terminal status", () => {
|
||||
expect(isRunTerminal("fetching_images")).toBe(false);
|
||||
expect(isRunTerminal("complete_with_problems")).toBe(true);
|
||||
});
|
||||
it("outcome labels", () => {
|
||||
expect(outcomeLabel("rejected_low_res")).toMatch(/resolution bar/);
|
||||
expect(outcomeLabel("failed")).toMatch(/unreachable/);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
// Pure logic behind the image-fetch surfaces (SD-0002 §5.5): the run-detail
|
||||
// progress/outcome labels + the history "Images" cell + terminal-status test
|
||||
// that drives polling. Mirrors exportMenu.ts: pure helpers unit-tested here,
|
||||
// the JSX verified by the E2E suite (SLICE-2/3 pattern).
|
||||
import type { ImageCounts } from "../../productsApi";
|
||||
|
||||
export function imageProgressLabel(p: { done: number; total: number }): string {
|
||||
return `Fetching images: ${p.done.toLocaleString()} of ${p.total.toLocaleString()}`;
|
||||
}
|
||||
|
||||
export function imageOutcomeSummary(c: ImageCounts): string {
|
||||
return `${c.fetched.toLocaleString()} fetched · ${c.rejected.toLocaleString()} rejected · ${c.failed.toLocaleString()} failed`;
|
||||
}
|
||||
|
||||
export function historyImageCell(c: ImageCounts | undefined): string {
|
||||
if (!c || c.fetched + c.rejected + c.failed === 0) return "—";
|
||||
const bad = c.rejected + c.failed;
|
||||
return bad === 0
|
||||
? `${c.fetched.toLocaleString()} ✓`
|
||||
: `${c.fetched.toLocaleString()} ✓ · ${bad.toLocaleString()} ✗`;
|
||||
}
|
||||
|
||||
export function isRunTerminal(status: string): boolean {
|
||||
return status === "complete" || status === "complete_with_problems";
|
||||
}
|
||||
|
||||
export function outcomeLabel(outcome: string): string {
|
||||
if (outcome === "rejected_low_res") return "Rejected — below the resolution bar";
|
||||
if (outcome === "rejected_not_image") return "Rejected — not a supported image";
|
||||
if (outcome === "failed") return "Failed — unreachable";
|
||||
return outcome;
|
||||
}
|
||||
@@ -1,394 +0,0 @@
|
||||
/* ecomm app chrome — the hf-kit primitives (Direction A · Quiet centered) as CSS.
|
||||
Depth = layered surfaces + hairlines; hover = small lift, no new shadows. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wv-midnight);
|
||||
color: var(--wv-starlight);
|
||||
font-family: var(--wv-font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── screen ground: barely-there starfield + optional gold horizon ─────────── */
|
||||
.screen {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||||
var(--wv-midnight);
|
||||
}
|
||||
.screen--horizon {
|
||||
background:
|
||||
radial-gradient(120% 70% at 50% 142%, rgba(244, 199, 107, .16) 0%, rgba(244, 199, 107, .05) 38%, transparent 64%),
|
||||
radial-gradient(1.3px 1.3px at 16% 22%, rgba(237, 234, 255, .34), transparent),
|
||||
radial-gradient(1.2px 1.2px at 78% 14%, rgba(237, 234, 255, .22), transparent),
|
||||
radial-gradient(1.1px 1.1px at 88% 46%, rgba(155, 140, 255, .30), transparent),
|
||||
radial-gradient(1.3px 1.3px at 30% 72%, rgba(237, 234, 255, .24), transparent),
|
||||
radial-gradient(1.1px 1.1px at 62% 86%, rgba(237, 234, 255, .18), transparent),
|
||||
radial-gradient(1.2px 1.2px at 7% 56%, rgba(155, 140, 255, .26), transparent),
|
||||
var(--wv-midnight);
|
||||
}
|
||||
.screen--plain { background: var(--wv-midnight); }
|
||||
|
||||
.screen__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
/* ── top bar: glass chrome ──────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
flex: 0 0 auto;
|
||||
height: 68px;
|
||||
padding: 0 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--glass-sky);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
.topbar__side { display: flex; align-items: center; gap: 16px; min-width: 0; }
|
||||
|
||||
/* ── wordmark ───────────────────────────────────────────────────────────────── */
|
||||
.wordmark { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.wordmark img { display: block; border-radius: 6px; }
|
||||
.wordmark__name {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 22px;
|
||||
letter-spacing: var(--tracking-display);
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
|
||||
/* ── links & text buttons ───────────────────────────────────────────────────── */
|
||||
.lk {
|
||||
color: var(--wv-lilac);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.lk:hover { color: var(--wv-gold); }
|
||||
.lk--mute { color: var(--text-on-dark-mute); }
|
||||
.lk--nav {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--wv-starlight);
|
||||
}
|
||||
|
||||
.signout {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14px;
|
||||
color: var(--wv-starlight);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color var(--dur-fast) var(--ease);
|
||||
}
|
||||
.signout:hover { color: var(--wv-gold); }
|
||||
|
||||
/* ── account chip (top-bar right, signed in) ────────────────────────────────── */
|
||||
.chip { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.chip__avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
background: var(--wv-lilac-16);
|
||||
border: 1px solid var(--border-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 13px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chip__email {
|
||||
font-size: 14px;
|
||||
color: var(--text-on-dark-soft);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip__divider { width: 1px; height: 18px; background: var(--border-soft); flex: 0 0 auto; }
|
||||
|
||||
/* ── eyebrow ────────────────────────────────────────────────────────────────── */
|
||||
.eyebrow {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: var(--text-eyebrow);
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
text-transform: uppercase;
|
||||
color: var(--wv-lilac);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── auth card — the one surface with a real shadow ─────────────────────────── */
|
||||
.card {
|
||||
width: 440px;
|
||||
max-width: 100%;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-card);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 40px 40px 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
box-shadow: var(--shadow-soft);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.card h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 27px;
|
||||
line-height: 1.08;
|
||||
margin: 0 0 9px;
|
||||
}
|
||||
.card__sub { font-size: 14.5px; line-height: 1.55; color: var(--text-on-dark-soft); margin: 0; }
|
||||
|
||||
/* ── fields ─────────────────────────────────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field__label {
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 13.5px;
|
||||
color: var(--text-on-dark-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.field__optional { font-size: 12.5px; color: var(--text-on-dark-mute); font-weight: var(--weight-regular); }
|
||||
.field__input {
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
padding: 0 16px;
|
||||
background: rgba(237, 234, 255, .035);
|
||||
border: 1.5px solid var(--border-soft);
|
||||
font-family: var(--wv-font-body);
|
||||
font-size: 16px;
|
||||
color: var(--wv-starlight);
|
||||
width: 100%;
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.field__input::placeholder { color: var(--text-on-dark-mute); }
|
||||
.field__input:hover { border-color: var(--border-strong); }
|
||||
.field__input:focus {
|
||||
outline: none;
|
||||
border-color: var(--wv-gold);
|
||||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||||
background: rgba(237, 234, 255, .06);
|
||||
}
|
||||
.field__input--error { border-color: var(--wv-gold); }
|
||||
|
||||
.note { font-size: 13px; line-height: 1.45; color: var(--text-on-dark-mute); margin: 0; }
|
||||
.note--attn { color: var(--wv-gold); display: flex; gap: 7px; align-items: baseline; }
|
||||
.note--attn::before { content: "◆"; font-size: 11px; }
|
||||
|
||||
/* ── primary button: gold pill ──────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .4em;
|
||||
width: 100%;
|
||||
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 transparent;
|
||||
background: var(--cta);
|
||||
color: var(--cta-text);
|
||||
cursor: pointer;
|
||||
transition: transform var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--cta-hover); transform: var(--lift-1); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary--auto { width: auto; }
|
||||
.btn-primary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
||||
|
||||
/* ── banner: gold attention / lilac info (no red in the palette) ────────────── */
|
||||
.banner {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
align-items: flex-start;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-on-dark-soft);
|
||||
text-align: left;
|
||||
}
|
||||
.banner--attn { background: rgba(244, 199, 107, .10); border: 1px solid var(--wv-gold-40); }
|
||||
.banner--info { background: var(--wv-lilac-08); border: 1px solid var(--wv-lilac-18); }
|
||||
.banner__icon { font-size: 13px; line-height: 22px; flex: 0 0 auto; }
|
||||
.banner--attn .banner__icon { color: var(--wv-gold); }
|
||||
.banner--info .banner__icon { color: var(--wv-lilac); }
|
||||
.banner__title { color: var(--wv-starlight); font-weight: var(--weight-semibold); margin-bottom: 2px; }
|
||||
|
||||
/* ── code input: 6 cells over one invisible input ───────────────────────────── */
|
||||
.code { position: relative; display: flex; gap: 11px; justify-content: center; }
|
||||
.code__hidden {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.code__cell {
|
||||
width: 52px;
|
||||
height: 64px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(237, 234, 255, .035);
|
||||
border: 1.5px solid var(--border-soft);
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 26px;
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
.code--focus .code__cell--active {
|
||||
border-color: var(--wv-gold);
|
||||
box-shadow: 0 0 0 3px var(--wv-gold-28);
|
||||
}
|
||||
.code--error .code__cell { border-color: var(--wv-gold); }
|
||||
|
||||
/* ── footer ─────────────────────────────────────────────────────────────────── */
|
||||
.footer {
|
||||
flex: 0 0 auto;
|
||||
padding: 20px 36px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-on-dark-mute);
|
||||
background: rgba(9, 12, 34, .4);
|
||||
}
|
||||
.footer__id { display: inline-flex; align-items: center; gap: 9px; }
|
||||
.footer__id img { opacity: .85; }
|
||||
|
||||
/* ── landing ────────────────────────────────────────────────────────────────── */
|
||||
.hero { max-width: 680px; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
.hero h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.02;
|
||||
font-size: clamp(36px, 6.5vw, 62px);
|
||||
margin: 20px 0 22px;
|
||||
}
|
||||
.hero h1 em { font-style: normal; color: var(--wv-gold); }
|
||||
.hero__lead {
|
||||
font-size: clamp(15.5px, 1.8vw, 18.5px);
|
||||
line-height: 1.6;
|
||||
color: var(--text-on-dark-soft);
|
||||
max-width: 520px;
|
||||
margin: 0 0 32px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.hero__actions { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||
.hero__actions .btn-primary { width: auto; }
|
||||
|
||||
.promises {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
width: 100%;
|
||||
margin-top: 44px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
.promises > div { padding: 0 22px; }
|
||||
.promises > div + div { border-left: 1px solid var(--border-soft); }
|
||||
.promises__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-medium);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
.promises__title::before { content: "◆"; color: var(--wv-gold); font-size: 10px; }
|
||||
.promises p { font-size: 13px; line-height: 1.5; color: var(--text-on-dark-mute); margin: 0; }
|
||||
|
||||
/* ── admin shell ────────────────────────────────────────────────────────────── */
|
||||
.storeid { display: inline-flex; align-items: center; gap: 11px; min-width: 0; }
|
||||
.storeid img { display: block; border-radius: 7px; flex: 0 0 auto; }
|
||||
.storeid__col { display: inline-flex; flex-direction: column; line-height: 1.12; min-width: 0; }
|
||||
.storeid__name {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.storeid__sub { font-size: 11px; color: var(--text-on-dark-mute); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 560px; }
|
||||
.empty__seal {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-card);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 26px;
|
||||
background: var(--wv-lilac-08);
|
||||
}
|
||||
.empty h1 {
|
||||
font-family: var(--wv-font-display);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
line-height: 1.04;
|
||||
font-size: clamp(30px, 4.5vw, 42px);
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
.empty__copy { font-size: 17px; line-height: 1.6; color: var(--text-on-dark-soft); max-width: 460px; margin: 0; text-wrap: pretty; }
|
||||
|
||||
/* ── small screens ──────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 720px) {
|
||||
.topbar { padding: 0 20px; }
|
||||
.footer { padding: 16px 20px; flex-direction: column; }
|
||||
.card { padding: 28px 22px 24px; }
|
||||
.promises { grid-template-columns: 1fr; gap: 14px; }
|
||||
.promises > div { padding: 0; }
|
||||
.promises > div + div { border-left: none; }
|
||||
.code__cell { width: 44px; height: 56px; font-size: 22px; }
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/* Wiggleverse webfonts (from the design-system export) — Space Grotesk (display) +
|
||||
Inter (body/UI). Fraunces (human register) deliberately omitted: no screen uses it yet. */
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/space-grotesk-v22-latin-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/fonts/space-grotesk-v22-latin-700.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-500.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/fonts/inter-v20-latin-600.woff2") format("woff2");
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/* Global style entry point — Wiggleverse design-system tokens (copied from the
|
||||
ui/designs export) + the ecomm app chrome built on them. */
|
||||
@import "./fonts.css";
|
||||
@import "./tokens-colors.css";
|
||||
@import "./tokens-typography.css";
|
||||
@import "./tokens-spacing.css";
|
||||
@import "./app.css";
|
||||
@import "./products.css";
|
||||
@@ -1,303 +0,0 @@
|
||||
/* 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; }
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/* Wiggleverse — Color tokens
|
||||
Source of truth: wiggleverse-www/assets/tokens.css (brand BRAND.md §8–9).
|
||||
Dark "sky" is the primary ground; Paper is for long reading. No-center motif. */
|
||||
|
||||
:root {
|
||||
/* ---- Brand palette (base values) ---- */
|
||||
--wv-midnight: #0E1230; /* sky / ground — primary dark background */
|
||||
--wv-indigo: #1C2150; /* raised surfaces on dark (cards, mobile nav) */
|
||||
--wv-indigo-2: #232A63; /* hover state for raised surfaces */
|
||||
--wv-lilac: #9B8CFF; /* accent — "the bonds between us"; links, nodes */
|
||||
--wv-violet: #7C6FE0; /* secondary links / strokes (on light) */
|
||||
--wv-gold: #F4C76B; /* warmth / horizon / primary CTAs */
|
||||
--wv-gold-hi: #F7D488; /* gold hover */
|
||||
--wv-starlight:#EDEAFF; /* nodes / text on dark */
|
||||
--wv-paper: #F6F4FB; /* light-mode background */
|
||||
--wv-ink: #3B2F7A; /* text on light */
|
||||
--wv-night: #090C22; /* footer / deepest ground */
|
||||
|
||||
/* CTA text-on-gold (very dark gold-brown, not pure black) */
|
||||
--wv-gold-ink: #2A2003;
|
||||
--wv-gold-ink-soft: #6B4E10; /* "soon" tag text on gold tint */
|
||||
|
||||
/* ---- Alpha derivations (lilac / gold / starlight washes) ---- */
|
||||
--wv-lilac-08: rgba(155, 140, 255, .08);
|
||||
--wv-lilac-12: rgba(155, 140, 255, .12);
|
||||
--wv-lilac-16: rgba(155, 140, 255, .16);
|
||||
--wv-lilac-18: rgba(155, 140, 255, .18);
|
||||
--wv-lilac-32: rgba(155, 140, 255, .32);
|
||||
--wv-gold-28: rgba(244, 199, 107, .28);
|
||||
--wv-gold-40: rgba(244, 199, 107, .40);
|
||||
--wv-starlight-85: rgba(237, 234, 255, .85);
|
||||
--wv-starlight-78: rgba(237, 234, 255, .78);
|
||||
--wv-starlight-60: rgba(237, 234, 255, .60);
|
||||
--wv-starlight-55: rgba(237, 234, 255, .55);
|
||||
|
||||
/* ---- Semantic aliases ---- */
|
||||
--surface-sky: var(--wv-midnight); /* page ground (dark) */
|
||||
--surface-raised: var(--wv-indigo); /* cards / panels on dark */
|
||||
--surface-raised-hi: var(--wv-indigo-2); /* raised hover */
|
||||
--surface-paper: var(--wv-paper); /* long-reading light sections */
|
||||
--surface-card-light:#FFFFFF; /* build-cards on paper */
|
||||
--surface-footer: var(--wv-night);
|
||||
|
||||
--text-on-dark: var(--wv-starlight);
|
||||
--text-on-dark-soft: var(--wv-starlight-78);
|
||||
--text-on-dark-mute: var(--wv-starlight-60);
|
||||
--text-on-light: var(--wv-ink);
|
||||
--text-on-light-soft:#4B4170;
|
||||
|
||||
--accent: var(--wv-lilac); /* links + nodes on dark */
|
||||
--accent-on-light: var(--wv-violet); /* links + strokes on light */
|
||||
--cta: var(--wv-gold); /* primary action / horizon */
|
||||
--cta-hover: var(--wv-gold-hi);
|
||||
--cta-text: var(--wv-gold-ink);
|
||||
|
||||
--border-soft: var(--wv-lilac-16); /* hairlines on dark */
|
||||
--border-card: var(--wv-lilac-18);
|
||||
--border-strong: var(--wv-lilac-32);
|
||||
--focus-ring: var(--wv-gold);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/* Wiggleverse — Spacing, radius, shadow, layout & motion tokens
|
||||
Derived from the marketing-site CSS. The brand has almost no shadow system —
|
||||
depth is carried by surface color + hairline borders, not drop shadows. */
|
||||
|
||||
:root {
|
||||
/* ---- Spacing scale (rem) ---- */
|
||||
--space-0: 0;
|
||||
--space-1: .25rem;
|
||||
--space-2: .5rem;
|
||||
--space-3: .7rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.2rem;
|
||||
--space-6: 1.6rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
|
||||
/* Section rhythm — fluid vertical padding for page bands */
|
||||
--section-pad: clamp(3rem, 7vw, 5.5rem);
|
||||
--page-hero-pad: clamp(2.8rem, 6vw, 4.5rem);
|
||||
|
||||
/* ---- Layout ---- */
|
||||
--wrap-max: 1080px; /* content column */
|
||||
--wrap-gutter: 92vw; /* width: min(--wrap-max, --wrap-gutter) */
|
||||
--measure-prose: 68ch; /* reading measure for prose blocks */
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-card: 14px; /* path-cards, build-cards */
|
||||
--radius-panel: 12px; /* principle tiles */
|
||||
--radius-sm: 4px; /* focus ring rounding */
|
||||
--radius-pill: 999px; /* buttons, tags, notices */
|
||||
--radius-mark: 26px; /* favicon tile rounding */
|
||||
|
||||
/* ---- Borders ---- */
|
||||
--border-hair: 1px; /* default hairline */
|
||||
--border-card-w: 1px;
|
||||
--btn-border-w: 1.5px; /* ghost button / focus weight */
|
||||
|
||||
/* ---- Elevation ---- *
|
||||
* The system avoids drop shadows. "Lift" on hover is a -1 to -3px translateY,
|
||||
* not a shadow. These tokens exist for the rare card that needs real elevation. */
|
||||
--shadow-none: none;
|
||||
--shadow-soft: 0 8px 24px rgba(9, 12, 34, .28);
|
||||
--lift-1: translateY(-1px); /* @kind other */ /* buttons */
|
||||
--lift-3: translateY(-3px); /* @kind other */ /* cards */
|
||||
|
||||
/* ---- Glass (sticky header) ---- */
|
||||
--glass-sky: rgba(14, 18, 48, .82);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ---- Motion ---- */
|
||||
--ease: ease; /* @kind other */
|
||||
--dur-fast: .15s; /* @kind other */ /* hover / press transitions */
|
||||
--dur-mid: .25s; /* @kind other */ /* mobile nav reveal */
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/* Wiggleverse — Typography tokens
|
||||
Two registers, one meaning (BRAND.md):
|
||||
• Machine register — Space Grotesk (display) + Inter (body/UI): precise, structural.
|
||||
• Human register — Fraunces, ITALIC ONLY: warm, literary, used sparingly
|
||||
for the lines that carry conscience ("the soul").
|
||||
@font-face rules live in assets/fonts/fonts.css. */
|
||||
|
||||
:root {
|
||||
/* ---- Families ---- */
|
||||
--wv-font-display: 'Space Grotesk', system-ui, sans-serif; /* headings, wordmark */
|
||||
--wv-font-body: 'Inter', system-ui, sans-serif; /* body / UI */
|
||||
--wv-font-human: 'Fraunces', Georgia, serif; /* pull-quotes — italic only */
|
||||
|
||||
/* semantic aliases */
|
||||
--font-display: var(--wv-font-display);
|
||||
--font-body: var(--wv-font-body);
|
||||
--font-soul: var(--wv-font-human);
|
||||
|
||||
/* ---- Weights ---- */
|
||||
--weight-regular: 400; /* Inter body */
|
||||
--weight-medium: 500; /* nav, eyebrows, buttons, Space Grotesk text */
|
||||
--weight-semibold:600; /* Inter emphasis, ledger totals */
|
||||
--weight-bold: 700; /* Space Grotesk headings, wordmark */
|
||||
--weight-soul: 500; /* Fraunces italic pull-quotes */
|
||||
|
||||
/* ---- Fluid display sizes (clamp: min, vw, max) ---- */
|
||||
--text-h1: clamp(2.1rem, 5.2vw, 3.6rem);
|
||||
--text-h2: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
--text-h3: 1.2rem;
|
||||
--text-lead: clamp(1.05rem, 1.8vw, 1.3rem); /* intro paragraph */
|
||||
--text-soul: clamp(1.2rem, 2.4vw, 1.7rem); /* hero pull-quote */
|
||||
|
||||
/* ---- Body / UI scale ---- */
|
||||
--text-body: 1rem; /* 16px base */
|
||||
--text-small: .95rem;
|
||||
--text-fine: .92rem;
|
||||
--text-eyebrow: .8rem; /* uppercase label */
|
||||
--text-tag: .72rem; /* pill tags */
|
||||
|
||||
/* ---- Line heights ---- */
|
||||
--leading-tight: 1.12; /* headings */
|
||||
--leading-body: 1.6; /* paragraphs */
|
||||
|
||||
/* ---- Letter spacing ---- */
|
||||
--tracking-display: -0.015em; /* headings + wordmark draw in slightly */
|
||||
--tracking-eyebrow: 0.12em; /* uppercase eyebrows open up */
|
||||
--tracking-tag: 0.08em;
|
||||
--tracking-notice: 0.06em;
|
||||
|
||||
/* ---- Measure ---- */
|
||||
--measure-lead: 60ch;
|
||||
--measure-prose:68ch;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Six-cell one-time-code input: one real <input> (invisible, full-bleed) carries the value
|
||||
// and the OTC autofill semantics; the cells are presentation. Click anywhere to focus;
|
||||
// paste works because the input is real.
|
||||
import { useState } from "react";
|
||||
|
||||
const LENGTH = 6;
|
||||
|
||||
export default function CodeInput({
|
||||
value,
|
||||
onChange,
|
||||
error = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
error?: boolean;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const digits = value.slice(0, LENGTH).split("");
|
||||
const active = Math.min(digits.length, LENGTH - 1);
|
||||
|
||||
return (
|
||||
<div className={`code${focused ? " code--focus" : ""}${error ? " code--error" : ""}`}>
|
||||
<input
|
||||
className="code__hidden"
|
||||
value={value}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
aria-label="One-time code"
|
||||
autoFocus
|
||||
maxLength={LENGTH}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onChange={(ev) => onChange(ev.target.value.replace(/\D/g, "").slice(0, LENGTH))}
|
||||
/>
|
||||
{Array.from({ length: LENGTH }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
aria-hidden="true"
|
||||
className={`code__cell${i === active && focused ? " code__cell--active" : ""}`}
|
||||
>
|
||||
{digits[i] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// The ecomm UI kit — React faces of the app.css primitives, matching the Claude Design
|
||||
// export (ui/designs/ecomm-login-and-create-storefront-designs, hf-kit). Presentation
|
||||
// only: no business rules live here (§6.2 — the SPA renders what the BFF returns).
|
||||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Screen({
|
||||
children,
|
||||
horizon = false,
|
||||
plain = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
horizon?: boolean;
|
||||
plain?: boolean;
|
||||
}) {
|
||||
const cls = plain ? "screen screen--plain" : horizon ? "screen screen--horizon" : "screen";
|
||||
return <div className={cls}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TopBar({ left, right }: { left: ReactNode; right?: ReactNode }) {
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="topbar__side">{left}</div>
|
||||
<div className="topbar__side">{right}</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Wordmark({ size = 26 }: { size?: number }) {
|
||||
return (
|
||||
<span className="wordmark">
|
||||
<img src="/brand/mark-tile.svg" width={size} height={size} alt="" />
|
||||
<span className="wordmark__name" style={{ fontSize: size * 0.86 }}>
|
||||
ecomm
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<span className="footer__id">
|
||||
<img src="/brand/mark-mono-gold.svg" width={16} height={16} alt="" />
|
||||
ecomm · a Wiggleverse line
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthCard({ children }: { children: ReactNode }) {
|
||||
return <div className="card">{children}</div>;
|
||||
}
|
||||
|
||||
export function Eyebrow({ children }: { children: ReactNode }) {
|
||||
return <p className="eyebrow">{children}</p>;
|
||||
}
|
||||
|
||||
export function Banner({
|
||||
tone = "attn",
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
tone?: "attn" | "info";
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`banner banner--${tone}`} role={tone === "attn" ? "alert" : "status"}>
|
||||
<span className="banner__icon" aria-hidden="true">
|
||||
{tone === "attn" ? "◆" : "◇"}
|
||||
</span>
|
||||
<div>
|
||||
{title && <div className="banner__title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrimaryButton({
|
||||
children,
|
||||
busy = false,
|
||||
disabled = false,
|
||||
type = "submit",
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: "submit" | "button";
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="btn-primary" type={type} disabled={disabled || busy} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
optional = false,
|
||||
error,
|
||||
helper,
|
||||
inputProps,
|
||||
}: {
|
||||
label: string;
|
||||
optional?: boolean;
|
||||
error?: string | null;
|
||||
helper?: string;
|
||||
inputProps: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field__label" htmlFor={id}>
|
||||
<span>{label}</span>
|
||||
{optional && <span className="field__optional">optional</span>}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
className={`field__input${error ? " field__input--error" : ""}`}
|
||||
aria-invalid={!!error}
|
||||
{...inputProps}
|
||||
/>
|
||||
{error && (
|
||||
<p className="note note--attn" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helper && !error && <p className="note">{helper}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountChip({ email, onSignOut }: { email: string; onSignOut: () => void }) {
|
||||
return (
|
||||
<div className="chip">
|
||||
<span className="chip__avatar" aria-hidden="true">
|
||||
{email[0]?.toUpperCase()}
|
||||
</span>
|
||||
<span className="chip__email">{email}</span>
|
||||
<span className="chip__divider" aria-hidden="true" />
|
||||
<button type="button" className="signout" onClick={onSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// The SPA talks to the FastAPI backend on :8000 via a same-origin proxy, so the
|
||||
// browser makes no cross-origin calls in dev. Override the target with
|
||||
// VITE_API_TARGET when :8000 is occupied. Screen-shaped /api/* endpoints arrive in
|
||||
// SLICE-2/3 (SD-0001 §6.4).
|
||||
const apiTarget = process.env.VITE_API_TARGET || "http://localhost:8000";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": apiTarget,
|
||||
},
|
||||
},
|
||||
// Tests live alongside source; scoping the include to src/ keeps the runner out of
|
||||
// node_modules (and any stray local backup dirs) without naming them.
|
||||
test: {
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
+2
-7
@@ -2,7 +2,8 @@
|
||||
# The single pre-merge gate for wiggleverse-ecomm (SD-0001 §6.8). Runs, in order:
|
||||
# 1. the import-linter layering contract (main > domains > platform)
|
||||
# 2. the backend test suite (pytest, against Postgres)
|
||||
# 3. the frontend typecheck + production build
|
||||
# The network-seed reset stripped the SPA frontend, so the frontend typecheck/build
|
||||
# and vitest steps are gone — this is a backend-only gate now.
|
||||
# CI (.gitea/workflows/ci.yml) calls this exact script, so local and server gates
|
||||
# cannot drift. Exits non-zero on the first failure.
|
||||
#
|
||||
@@ -31,10 +32,4 @@ echo "==> import boundaries (lint-imports)"
|
||||
echo "==> backend tests (pytest)"
|
||||
( cd "$repo_root/backend" && "$PY" -m pytest -q )
|
||||
|
||||
echo "==> frontend typecheck + build"
|
||||
( cd "$repo_root/frontend" && npm run build )
|
||||
|
||||
echo "==> frontend unit tests (vitest)"
|
||||
( cd "$repo_root/frontend" && npm test )
|
||||
|
||||
echo "==> all gates green"
|
||||
|
||||
+10
-43
@@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local bring-up for ecomm (SD-0001 PUC-10). From a clean checkout this:
|
||||
# Local bring-up for the ecomm network-service seed (SD-0001 PUC-10). From a clean
|
||||
# checkout this:
|
||||
# 1. starts the dev Postgres container and waits for it to be healthy (owns its
|
||||
# lifecycle — Docker is the one prerequisite, docs/BOOTSTRAP.md);
|
||||
# 2. ensures the backend venv + deps and the frontend node_modules;
|
||||
# 3. runs the FastAPI backend (:8000, self-migrating an empty DB) and the Vite dev
|
||||
# server (:5173, proxying /api -> :8000) together.
|
||||
# Ctrl-C stops both processes; the container keeps running (stop it with
|
||||
# 2. ensures the backend venv + deps;
|
||||
# 3. runs the FastAPI backend (:8000, self-migrating an empty DB).
|
||||
# Ctrl-C stops the backend; the container keeps running (stop it with
|
||||
# `docker compose down`, or `docker compose down -v` to reset to empty).
|
||||
# The storefront SPA was removed in the network-seed reset — there is no frontend dev
|
||||
# server yet (the network admin UI is a future build).
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
@@ -24,41 +26,6 @@ if [ ! -x "$repo_root/.venv/bin/python" ]; then
|
||||
"$repo_root/.venv/bin/python" -m pip install -r "$repo_root/backend/requirements.txt"
|
||||
fi
|
||||
|
||||
if [ ! -d "$repo_root/frontend/node_modules" ]; then
|
||||
echo "==> installing frontend deps"
|
||||
( cd "$repo_root/frontend" && npm install )
|
||||
fi
|
||||
|
||||
echo "==> starting backend (:8000) and Vite (:5173) — Ctrl-C to stop"
|
||||
"$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload &
|
||||
backend_pid=$!
|
||||
( cd "$repo_root/frontend" && npm run dev ) &
|
||||
frontend_pid=$!
|
||||
|
||||
# Kill a process and its whole descendant tree (children first), portably across
|
||||
# macOS and Linux — npm spawns vite as a grandchild, so killing the captured pid
|
||||
# alone would orphan it on :5173. `pgrep -P` (list children) exists on both.
|
||||
kill_tree() {
|
||||
local pid=$1 child
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do
|
||||
kill_tree "$child"
|
||||
done
|
||||
kill "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
trap - INT TERM
|
||||
echo
|
||||
echo "==> stopping backend and Vite"
|
||||
kill_tree "$backend_pid"
|
||||
kill_tree "$frontend_pid"
|
||||
wait "$backend_pid" "$frontend_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup INT TERM
|
||||
|
||||
# Wait until either process exits, then tear the other down. `wait -n` would be
|
||||
# tidier but is unsupported on macOS's bundled bash 3.2, so poll portably.
|
||||
while kill -0 "$backend_pid" 2>/dev/null && kill -0 "$frontend_pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
cleanup
|
||||
echo "==> starting backend (:8000) — Ctrl-C to stop"
|
||||
# exec hands the terminal (and Ctrl-C) straight to uvicorn; no child-process juggling.
|
||||
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --app-dir "$repo_root/backend" --port 8000 --reload
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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