Compare commits

...

73 Commits

Author SHA1 Message Date
ben.stull 385df8d728 Merge pull request 'SLICE-6: export & the round-trip lock — canonical serializer + streamed export (SD-0002 §7.2)' (#29) from worktree-slice-6-export-roundtrip into main
ci / check (push) Has been cancelled
2026-06-12 05:23:23 +00:00
ben.stull af72299a57 fix(products): serialize Variant Position explicitly — INV-12 round-trip bug
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
A stored variant position is a CatalogVariant attribute, not a fields{} entry,
so the serializer emitted an empty Variant Position cell — which re-imports as
'reset to file order'. A non-sequential stored position (a merchant can import
explicit positions) then round-tripped to a spurious update, violating INV-12.
Emit str(variant.position) explicitly (like _write_image). The property-test
generator now assigns non-sequential positions to lock the regression, plus a
targeted test. Also wires isExportEnabled into ProductsPage (was dead code).
Both caught by the final code review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:22:11 -07:00
ben.stull 6f22c8b146 chore: version 0.6.0 — SLICE-6 export & round-trip
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:14:30 -07:00
ben.stull 767011fbae docs(products): DOC-1 export ops + TEL-3, DOC-4 serializer/round-trip (SLICE-6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:14:30 -07:00
ben.stull 8077f2e07b test(e2e): e2e_export_download + e2e_roundtrip_noop (SD-0002 §6.8, PUC-9/10)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:12:53 -07:00
ben.stull 0a85c4fef8 feat(frontend): Export status-filter menu on the Products page (SD-0002 §5.2, PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:08:34 -07:00
ben.stull 6f213e1f02 feat(frontend): export URL helper + status-filter list (PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:07:36 -07:00
ben.stull 155f9bd147 feat(api): GET /api/products/export — streamed canonical CSV, 409 empty_catalog (§6.4, PUC-9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:05:55 -07:00
ben.stull 1c2a6af986 feat(products): streamed export service + TEL-3 + EmptyCatalog (PUC-9, §9.1) 2026-06-11 22:03:09 -07:00
ben.stull 5a97b9dc59 feat(products): status-filtered export_catalog snapshot query (PUC-9) 2026-06-11 22:01:11 -07:00
ben.stull fce0b5eaed test(products): decimal/int variant fields round-trip clean (INV-12 coverage) 2026-06-11 21:59:06 -07:00
ben.stull b40e4d30b5 test(products): INV-12 property test — export round-trips to a no-op (SD-0002 §6.8) 2026-06-11 21:58:47 -07:00
ben.stull 7652e92cbe feat(products): serialize multi-variant + interleaved images (§6.5.1 row grammar) 2026-06-11 21:58:22 -07:00
ben.stull 627e257d4c feat(products): canonical serializer skeleton — header + single product row (SD-0002 §6.5.5) 2026-06-11 21:57:48 -07:00
ben.stull 011f4d5dc1 Merge pull request 'SLICE-5: products import spine — canonical CSV → preview → confirm (SD-0002 §7.2)' (#26) from worktree-slice-5-import-spine into main
ci / check (push) Has been cancelled
2026-06-12 03:50:20 +00:00
ben.stull 2606fbf826 docs(products): operator guide (DOC-1) + domain notes (DOC-4); version 0.5.0
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:00:13 -07:00
ben.stull ad738adbc0 test(e2e): SLICE-5 scenarios — preview/confirm, errors, rejection, cancel (§6.8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:53:59 -07:00
ben.stull a58f42cf86 test(e2e): stand up Playwright — fresh-DB server harness + sign-up helper
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:50:59 -07:00
ben.stull d18a84e135 fix(products-ui): review pass — show-more race, keyboard history access, a11y glyphs/aria-live, label consistency
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:46:43 -07:00
ben.stull 87a56a66c4 feat(products-ui): import run detail (§5.5, PUC-4/5/8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:35:49 -07:00
ben.stull 4141285b89 feat(products-ui): import preview — tiles, drill-in diffs, confirm/cancel (§5.4)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:34:33 -07:00
ben.stull 188494272a feat(products-ui): import upload screen (§5.3, PUC-2/5a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:30:51 -07:00
ben.stull 26f84cb916 feat(products-ui): admin nav + Products page with import history (§5.2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:29:20 -07:00
ben.stull b5dac8886f feat(products-ui): typed products API client + admin hash routing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:25:37 -07:00
ben.stull 4dacc2dafd feat(products): /api/products/* BFF endpoints + sample.csv (DOC-3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:18:34 -07:00
ben.stull a8538d3ecc fix(products): cleared Variant Position resolves to file order, not NULL
A blank Variant Position cell previewed position->null and aborted the
confirm transaction (variant.position is NOT NULL). Resolve the clear to
the variant's file order at diff time so preview and apply stay in
lockstep; carry file_order on VariantPlan instead of an identity-keyed
map; release the read snapshot before PreviewStale/NothingToApply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:14:14 -07:00
ben.stull 0c7865e9e1 feat(products): confirm/apply in one transaction, runs, summary — INV-10/11/14 + TEL-2/6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:02:27 -07:00
ben.stull fcbf1393f5 feat(products): import_validate → draft, preview records, discard + TEL-1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:51:03 -07:00
ben.stull 138126ab17 feat(products): repo — catalog snapshot, draft/run SQL, upsert primitives
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:42:42 -07:00
ben.stull 667a462e0b fix(products): honest position compare for matched variants in diff
A Variant Position column previously produced a spurious update with a
false before:None (CatalogVariant keeps position as an attribute, not in
fields{}). Compare against the attribute, like images already do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:37:59 -07:00
ben.stull 9bc6e4dbd2 feat(products): diff engine — add/update/unchanged/error + INV-11 fingerprint
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:28:03 -07:00
ben.stull 19ee695c20 feat(products): row validation — §6.5.1 rules + INV-15 sanitization
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:17:01 -07:00
ben.stull f64c3fddf9 feat(products): CSV codec — file-level parse + INV-18 caps (PUC-5a)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:58:53 -07:00
ben.stull c39bbd4728 feat(products): domain skeleton — errors, column registry, canonical row model
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:55:37 -07:00
ben.stull 0ee948b34d feat(products): SD-0002 §6.3 schema — migration 0002 + nh3/multipart deps (SLICE-5)
Also updates test_migrate_from_empty_applies_0001 → test_migrate_from_empty_applies_all
to assert both migrations apply on a fresh DB (mechanical consequence of adding 0002).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:52:18 -07:00
ben.stull 1267d4f29d docs: SLICE-5 implementation plan (SD-0002 §7.2 import spine)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:48:47 -07:00
ben.stull 6b405a0f2d Merge pull request 'fix(signin): key the resend cooldown to the address it was set for (#20)' (#22) from fix-20-signin-cooldown-keying into main
ci / check (push) Has been cancelled
2026-06-11 08:22:08 +00:00
ben.stull 118e925580 fix(signin): key the resend cooldown to the address it was set for (#20)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
The email-step Send button disabled on any running cooldown, so a merchant
who mistyped their address was locked out of sending to the corrected one
for the rest of the 60s window — a guard the server never had: request_code
enforces the cooldown per address (SD-0001 INV-3 -> PUC-2c).

Track which address the running cooldown belongs to (cooldownFor) and gate
the email-step button through cooldownAppliesTo(), which blocks only while
the countdown runs AND the input normalizes (strip+lowercase, mirroring
normalize_email / INV-2) to that address. Same address still shows the
honest 'Resend in Ns'; any other address sends immediately. The code-step
resend is unchanged. Verified in the live UI (wrong address -> Wrong
address? -> corrected address sends at once).

package-lock.json: sync recorded version with package.json (0.4.0).

Closes #20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:21:23 -07:00
ben.stull 11acb3a5b1 Merge pull request 'docs: land session 0023 plan doc (stray untracked file)' (#18) from docs-archive-0023-plan into main
ci / check (push) Has been cancelled
2026-06-11 07:56:34 +00:00
ben.stull efc9c8edb9 docs: land session 0023's ui-designs-collection plan in-repo (was untracked; content archive already had it)
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:56:31 -07:00
ben.stull f7b76d5370 Merge pull request 'SLICE-4: record the first PPE bootstrap rehearsal (PUC-11) — green' (#17) from slice-4-rehearsal-record into main
ci / check (push) Has been cancelled
2026-06-11 07:54:50 +00:00
ben.stull 4bb9763633 docs(slice-4): record the first PPE bootstrap rehearsal (PUC-11, 2026-06-11) — green
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:54:47 -07:00
ben.stull a456d51f17 Merge pull request 'SLICE-4: deployment.toml — the ecomm PPE record' (#11) from slice-4-deployment-record into main
ci / check (push) Has been cancelled
2026-06-11 06:45:08 +00:00
ben.stull ac3c4ffe36 feat(slice-4): deployment.toml — the ecomm PPE record (launch-app §5.1; Cloud SQL via secret ref)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:45:05 -07:00
ben.stull 8a2ec24d86 Merge pull request 'SLICE-4 (part 1): deploy contract — versioned health, backend-served SPA, SmtpMailer + honest 502 (SD-0001 §7.2)' (#10) from slice-4-deploy-contract into main
ci / check (push) Has been cancelled
2026-06-11 06:36:26 +00:00
ben.stull acff7ac588 docs(slice-4): BOOTSTRAP.md PPE runbook, version 0.4.0, README status
ci / check (pull_request) Has been cancelled
ci / check (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:36:24 -07:00
ben.stull 22d738fc74 feat(slice-4): honest delivery failure — send-before-commit + 502 delivery_failed (INV-9; closes #7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:35:01 -07:00
ben.stull 1b8c60fcb5 feat(slice-4): SmtpMailer — real mail over a configured relay, log-hygienic (INV-8, §6.6)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:33:40 -07:00
ben.stull 9f4295b77e feat(slice-4): backend serves the built SPA — the deployed nginx-proxies-all topology (launch-app §2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:32:22 -07:00
ben.stull a1c5544694 feat(slice-4): VERSION file + versioned /healthz — the flotilla verify contract (§8.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:20 -07:00
ben.stull 0747181157 Merge pull request 'SLICE-3: storefront — create + INV-4 guard, Create/Admin screens, full entry routing (SD-0001 §7.2)' (#9) from slice-3-storefront into main
ci / check (push) Has been cancelled
2026-06-11 05:47:02 +00:00
ben.stull 877be249c7 chore(slice-3): version 0.3, README status, label-input association fix (a11y §6.6)
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:46:12 -07:00
ben.stull 25ac540171 feat(slice-3): Admin shell + complete entry routing (§5.4/§6.5, PUC-6/8/9)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:41:38 -07:00
ben.stull 10d22938f1 feat(slice-3): Create-storefront screen + API helper (§5.3, PUC-4/5/7)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:41:04 -07:00
ben.stull ddc949615e feat(slice-3): Sign-in re-skinned — code cells, cooldown countdown, honest §5.2 states
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:40:33 -07:00
ben.stull 0b302ea1e1 feat(slice-3): Landing re-skinned to the Claude Design export (§5.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:39:57 -07:00
ben.stull ef1498ba08 feat(slice-3): design-system foundation — Wiggleverse tokens, fonts, brand marks, UI kit (per ui/designs export)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:39:30 -07:00
ben.stull cc9d9dda5f test(slice-3): INV-1 whole-flow bootstrap test — empty DB to admin answer (§6.8)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:37:10 -07:00
ben.stull 306b5c1e5d feat(slice-3): POST /api/storefronts + storefronts-fed entry routing (§6.4/§6.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:36:50 -07:00
ben.stull e11fb032b1 test(slice-3): INV-4 invariants — concurrent-create refusal + many-capable membership schema
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:34:24 -07:00
ben.stull ba8b493a31 feat(slice-3): storefronts domain — create + membership + INV-4 guard (§6.5)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:33:57 -07:00
ben.stull 1b40bde4a0 Merge pull request 'SLICE-2: identity — email + one-time-code sign-up, sessions, Landing/Sign-in (SD-0001 §7.2)' (#6) from slice-2-identity into main
ci / check (push) Has been cancelled
2026-06-10 17:51:18 +00:00
ben.stull 725877c5c6 docs(slice-2): implementation plan for the identity slice
ci / check (push) Has been cancelled
ci / check (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:43:09 -07:00
ben.stull bf62f413d7 docs(slice-2): README status — identity slice in place
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:42:49 -07:00
ben.stull cfc28d7002 fix(slice-2): surface ecomm.* INFO logs so the one-time code reaches the dev log
uvicorn configures only its own loggers, so the LogMailer INFO line — PUC-10's local dev
channel (the code in the backend log) — was swallowed. Add an idempotent dedicated handler
for the 'ecomm' logger (propagate=False) in create_app so dev.sh/uvicorn runs show the code.
Caught by the SLICE-2 end-to-end walk; unit tests read the in-memory outbox and didn't.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:42:30 -07:00
ben.stull b3ffb2d4b6 feat(slice-2): Landing + Sign-in screens, entry routing, API client (§5.1/5.2/6.5)
Two-step email + one-time-code sign-in behind both doors; pure entry-routing rule unit-
tested with Vitest (§6.8); storefront routing reaches a SLICE-2 signed-in placeholder with
sign-out (PUC-9). check.sh now runs the frontend unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:39:48 -07:00
ben.stull cfd2b4ecc7 feat(slice-2): BFF /api/auth/* — request-code, verify, me, logout (§6.4)
Screen-shaped JSON endpoints; signed-cookie session on verify; §6.4 error envelope;
storefront hard-wired null behind the _storefront_for seam (SLICE-3 fills it). No
enumeration: uniform 204. PUC-9 logout clears the cookie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:35:01 -07:00
ben.stull d2eceac272 feat(slice-2): deps — mailer + session dependencies; build mailer at startup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:34:13 -07:00
ben.stull 95680c9960 feat(slice-2): accounts domain — request_code/verify/get_account (INV-2/INV-3/INV-6)
Email-canonical get-or-create, peppered-hash one-time codes with 10-min TTL, single-use,
5-attempt cap, and 60s resend cooldown; uniform new-or-known (no enumeration, §6.6). All
identity rules in the domain layer once (INV-6); scenario + invariant tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:31:32 -07:00
ben.stull 2d15f1a2cb feat(slice-2): platform/session — stdlib HMAC signed cookie tokens (§6.2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:27:35 -07:00
ben.stull 446c13211a feat(slice-2): platform/mailer port + LogMailer (dev channel, §6.2/PUC-10)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:26:58 -07:00
ben.stull 7a6b396f65 feat(slice-2): config — session secret, cookie_secure, mailer_kind (INV-8)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:26:15 -07:00
ben.stull 9373177ed3 Merge pull request 'SLICE-1: walking skeleton (SD-0001 §7.2) — scaffold, dev Postgres, migration runner, /healthz' (#5) from claude/agitated-heyrovsky-1a1f47 into main
ci / check (push) Has been cancelled
2026-06-10 15:50:42 +00:00
115 changed files with 15313 additions and 35 deletions
+11 -5
View File
@@ -20,8 +20,14 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
## Status
SLICE-1 (walking skeleton) of SD-0001 is in place: a four-layer FastAPI backend that
self-migrates an empty PostgreSQL database at startup and serves `/healthz`, a
Vite/React shell, the `scripts/check.sh` gate, and the dev container lifecycle
(`scripts/dev.sh`). See [`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally.
Identity (SLICE-2) and the storefront (SLICE-3) are next.
SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
domain (create + owner membership, INV-4's one-storefront guard with concurrent-create
refusal), `POST /api/storefronts`, the Create-storefront and Admin-shell screens, the
complete entry-routing rule (landing / create-storefront / admin), and the INV-1
whole-flow bootstrap test — all skinned to the Claude Design export
(`wiggleverse-ecomm-content/ui/designs/ecomm-login-and-create-storefront-designs/`),
which also re-skinned the SLICE-2 Landing + Sign-in screens. See
[`docs/BOOTSTRAP.md`](./docs/BOOTSTRAP.md) to run it locally. SLICE-4's deploy
contract is in place (versioned `/healthz`, backend-served SPA, `SmtpMailer` with
honest 502 on delivery failure); PPE provisioning + the bootstrap rehearsal are the
remaining SLICE-4 steps (BOOTSTRAP.md's PPE section is the runbook).
+1
View File
@@ -0,0 +1 @@
0.6.0
+33
View File
@@ -0,0 +1,33 @@
"""accounts domain — identity: account records, one-time-code issue/verify, account lookup.
Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows
nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this
package surface only (§6.2).
"""
from __future__ import annotations
from .errors import (
AccountsError,
CodeExhausted,
CodeExpired,
CodeMismatch,
DeliveryFailed,
InvalidEmail,
ResendCooldown,
)
from .models import Account
from .service import get_account, request_code, verify
__all__ = [
"Account",
"AccountsError",
"InvalidEmail",
"ResendCooldown",
"CodeMismatch",
"CodeExpired",
"CodeExhausted",
"DeliveryFailed",
"request_code",
"verify",
"get_account",
]
+42
View File
@@ -0,0 +1,42 @@
"""accounts — domain exceptions.
Raised by the service layer; the BFF maps each to the §6.4 error shape. Keeping them here
(not in main) keeps the rules in the domain (INV-6).
"""
from __future__ import annotations
class AccountsError(Exception):
"""Base for accounts-domain errors."""
class InvalidEmail(AccountsError):
"""The supplied email is not a plausible address (§6.4 400 invalid_email)."""
class ResendCooldown(AccountsError):
"""A code was issued for this email under 60s ago (INV-3 → §6.4 429 resend_cooldown)."""
def __init__(self, retry_after_s: int) -> None:
super().__init__(f"resend available in {retry_after_s}s")
self.retry_after_s = retry_after_s
class CodeMismatch(AccountsError):
"""The submitted code is wrong (PUC-2a → §6.4 400 code_mismatch)."""
def __init__(self, attempts_remaining: int) -> None:
super().__init__("code did not match")
self.attempts_remaining = attempts_remaining
class CodeExpired(AccountsError):
"""No live code for this email — expired, consumed, or never issued (§6.4 400 code_expired)."""
class CodeExhausted(AccountsError):
"""The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted)."""
class DeliveryFailed(AccountsError):
"""The relay refused the code email; nothing was committed (INV-9 → §6.4 502 delivery_failed)."""
+12
View File
@@ -0,0 +1,12 @@
"""accounts — domain models (SD-0001 §6.3)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Account:
"""A person's identity on the platform. Email is the canonical key (INV-2)."""
id: int
email: str
+173
View File
@@ -0,0 +1,173 @@
"""accounts — the identity service (SD-0001 §6.5).
All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time
code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper,
10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account.
Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports
only app.platform (downward); takes the connection and mailer as parameters (the BFF wires
them) so the domain owns no request/transport concerns.
"""
from __future__ import annotations
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta, timezone
import psycopg
from app.platform import config
from app.platform.mailer import Mailer, MailerError
from .errors import (
CodeExhausted,
CodeExpired,
CodeMismatch,
DeliveryFailed,
InvalidEmail,
ResendCooldown,
)
from .models import Account
# INV-3 constants — the one-time-code policy.
CODE_TTL = timedelta(minutes=10)
RESEND_COOLDOWN = timedelta(seconds=60)
MAX_ATTEMPTS = 5
_CODE_DIGITS = 6
# A pragmatic email check — rejects obvious non-addresses without an email-validator
# dependency. The provable test of an address is that the emailed code comes back (verify).
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def normalize_email(email: str) -> str:
"""Lowercase + strip — the canonical form one account is keyed by (INV-2)."""
norm = email.strip().lower()
if not _EMAIL_RE.match(norm):
raise InvalidEmail(email)
return norm
def _hash_code(code: str) -> str:
"""HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code)."""
key = config.session_secret().encode("utf-8")
return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest()
def _now() -> datetime:
return datetime.now(timezone.utc)
def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
"""Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6).
Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's
spent/expired codes. Never reveals whether the email already has an account.
"""
email = normalize_email(email)
now = _now()
last = conn.execute(
"SELECT max(created_at) FROM auth_code WHERE email = %s AND consumed_at IS NULL",
(email,),
).fetchone()[0]
if last is not None:
elapsed = now - last
if elapsed < RESEND_COOLDOWN:
retry_after_s = int((RESEND_COOLDOWN - elapsed).total_seconds()) + 1
raise ResendCooldown(retry_after_s)
# Housekeeping: drop this email's consumed/expired codes (§6.3) before issuing a fresh one.
conn.execute(
"DELETE FROM auth_code WHERE email = %s AND (consumed_at IS NOT NULL OR expires_at < %s)",
(email, now),
)
code = "".join(secrets.choice("0123456789") for _ in range(_CODE_DIGITS))
conn.execute(
"INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
(email, _hash_code(code), now + CODE_TTL),
)
# Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan
# code blocks the 60s cooldown and the caller can honestly retry at once (INV-9).
try:
mailer.send(
to=email,
subject=f"Your ecomm code: {code}",
body=(
f"Your ecomm one-time code is {code}.\n"
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n"
"If you didn't request this, ignore this message."
),
)
except MailerError as exc:
conn.rollback()
raise DeliveryFailed(str(exc)) from exc
conn.commit()
def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]:
"""Verify a code and resolve the account. Returns (account, created).
Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt
(PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise
CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created
by normalized email (INV-2). Account creation IS email verification (the address
provably received the code).
"""
email = normalize_email(email)
now = _now()
row = conn.execute(
"SELECT id, code_hash, expires_at, attempts FROM auth_code "
"WHERE email = %s AND consumed_at IS NULL "
"ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
(email,),
).fetchone()
if row is None:
conn.commit()
raise CodeExpired(email) # nothing live to verify — offer a fresh code
code_id, code_hash, expires_at, attempts = row
if expires_at < now:
conn.commit()
raise CodeExpired(email)
if not hmac.compare_digest(code_hash, _hash_code(code)):
attempts += 1
if attempts >= MAX_ATTEMPTS:
conn.execute(
"UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s",
(attempts, now, code_id),
)
conn.commit()
raise CodeExhausted(email)
conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id))
conn.commit()
raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts)
# Correct: consume the code and get-or-create the account in the same transaction.
conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id))
account, created = _get_or_create_account(conn, email)
conn.commit()
return account, created
def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]:
"""Insert the account if absent; return (account, created). Race-safe via the unique index."""
inserted = conn.execute(
"INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id",
(email,),
).fetchone()
if inserted is not None:
return Account(id=inserted[0], email=email), True
existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone()
return Account(id=existing[0], email=email), False
def get_account(conn: psycopg.Connection, account_id: int) -> Account | None:
"""Load an account by id (for the signed-in `/me`)."""
row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone()
return Account(id=row[0], email=row[1]) if row else None
+43
View File
@@ -0,0 +1,43 @@
"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2).
Owns the canonical row model, codec, validation, diff engine, and import
drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation
(INV-10). Imported via this package surface only.
"""
from __future__ import annotations
from pathlib import Path
from .errors import (
DraftExpired,
DraftNotFound,
EmptyCatalog,
FileRejected,
NothingToApply,
PreviewStale,
ProductsError,
RunNotFound,
)
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
from .service import (
confirm_draft,
discard_draft,
export_catalog,
get_draft,
get_draft_records,
get_run,
import_validate,
list_runs,
summary,
)
# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv.
SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv"
__all__ = [
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog",
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH",
"import_validate", "get_draft", "get_draft_records", "discard_draft",
"confirm_draft", "list_runs", "get_run", "summary", "export_catalog",
]
+65
View File
@@ -0,0 +1,65 @@
"""CSV codec — bytes → ParsedFile (SD-0002 §6.5.1). File-level gates only (PUC-5a);
row semantics live in validate.py. Dialect detection is the INV-17 seam (SLICE-8
adds Shopify)."""
from __future__ import annotations
import csv
import io
from .errors import FileRejected
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
def detect_dialect(header: list[str]) -> str:
"""The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here."""
return "canonical"
def parse_csv(data: bytes) -> ParsedFile:
if len(data) > MAX_FILE_BYTES:
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
try:
text = data.decode("utf-8-sig")
except UnicodeDecodeError:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
reader = csv.reader(io.StringIO(text))
try:
try:
raw_header = next(reader)
except StopIteration:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
header = [h.strip() for h in raw_header]
for col in _REQUIRED_HEADER_COLUMNS:
if col not in header:
raise FileRejected(
"missing_required_column",
f"This file is missing the required column '{col}'.",
)
# First occurrence of a duplicated column wins.
col_index: dict[str, int] = {}
for i, name in enumerate(header):
if name and name not in col_index:
col_index[name] = i
known_present = [c for c in col_index if c in KNOWN_COLUMNS]
unknown = [c for c in col_index if c not in KNOWN_COLUMNS]
rows: list[Row] = []
for raw in reader:
if not any(cell.strip() for cell in raw):
continue
if len(rows) >= MAX_DATA_ROWS:
raise FileRejected(
"too_many_rows",
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
)
cells = {
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
for c in known_present
}
rows.append(Row(line_number=reader.line_num, cells=cells))
except csv.Error:
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
return ParsedFile(
dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows
)
+322
View File
@@ -0,0 +1,322 @@
"""Diff engine — catalog × canonical products → apply plan + preview records (SD-0002 §6.5.2).
Classifies each canonical product against the storefront's current catalog as
add / update / unchanged / error. One walk produces two views of the same
computation: a typed apply *plan* carrying resolved native values (Decimal etc.)
for the confirm transaction, and JSON-ready preview records derived from that
walk (stored as draft JSONB, served verbatim to the SPA) — so what confirm
applies is exactly what preview showed (INV-11). A summary and a deterministic
fingerprint over the records detect catalog drift between preview and confirm.
Deliberately DB-free: the catalog snapshot dataclasses are defined here and the
repo layer builds them. Only fields present in the file's canonical fields{}
participate in a comparison — an absent column is untouched, never a change
(§6.5.1); a present-but-empty cell resolves to the field's CLEAR_DEFAULTS entry
— except a variant's position, whose default is the variant's 1-based file
order within its product ("defaults to file order"), resolved here at diff time.
Catalog variants/images absent from the file are likewise untouched (INV-10);
file variants match catalog variants by their option-value combination (INV-13).
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from decimal import Decimal
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant
@dataclass
class CatalogVariant:
id: int
options: tuple[str | None, str | None, str | None]
position: int
# sku, barcode, price (Decimal|None), cost, weight, weight_unit, volume,
# volume_unit, tax_id_1, tax_id_2, inventory_tracker, inventory_qty,
# variant_image (linked image source_url or None)
fields: dict[str, object]
@dataclass
class CatalogImage:
id: int
source_url: str
position: int
alt_text: str | None
@dataclass
class CatalogProduct:
id: int
handle: str
title: str
option_names: tuple[str | None, str | None, str | None]
# title, description_html, vendor, product_type, google_product_category,
# tags (list[str]), status, published (bool)
fields: dict[str, object]
variants: list[CatalogVariant]
images: list[CatalogImage]
# ---------------------------------------------------------------------------
# Apply plan — the typed twin of the preview records. confirm_draft executes
# these; the values are resolved natives (Decimal, bool, list), never the
# json-safe strings the records carry for display.
# ---------------------------------------------------------------------------
@dataclass
class VariantPlan:
kind: str # "add" | "update"
canonical: CanonicalVariant
catalog_id: int | None # None for add
# 1-based index within the product's file variants — the position default.
file_order: int = 0
# field -> resolved after-value (update only); adds resolve from canonical.fields
changes: dict[str, object] = field(default_factory=dict)
@dataclass
class ImagePlan:
kind: str # "add" | "update"
source_url: str
position: int
alt_text: str | None
image_id: int | None # None for add
changes: dict[str, object] = field(default_factory=dict) # subset of {"position","alt_text"}
@dataclass
class ProductPlan:
kind: str # "add" | "update" | "unchanged" | "error"
canonical: CanonicalProduct
catalog: CatalogProduct | None
# field -> resolved after-value (update only; may include title/option*_name)
product_changes: dict[str, object] = field(default_factory=dict)
variant_plans: list[VariantPlan] = field(default_factory=list)
image_plans: list[ImagePlan] = field(default_factory=list)
@dataclass(frozen=True)
class DiffResult:
records: list[dict]
summary: dict
fingerprint: str
plan: list[ProductPlan]
# Option-name presence markers in fields{} (see validate.py); the values are
# compared via the option_names attribute, not through the generic field loop.
_OPTION_NAME_FIELDS = ("option1_name", "option2_name", "option3_name")
_SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "error": "errors"}
def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult:
records: list[dict] = []
plan: list[ProductPlan] = []
summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0}
for product in products:
current = catalog.get(product.handle)
if product.errors:
product_plan = ProductPlan(kind="error", canonical=product, catalog=current)
detail: dict = {"errors": [e.as_json() for e in product.errors]}
elif current is None:
product_plan = _add_plan(product)
detail = _add_detail(product_plan)
else:
product_plan, detail = _update_plan(product, current)
kind = product_plan.kind
plan.append(product_plan)
records.append(
{
"handle": product.handle,
"title": product.title or (current.title if current else ""),
"kind": kind,
"variant_count": len(current.variants) if kind == "unchanged" else len(product.variants),
"detail": detail,
}
)
summary[_SUMMARY_KEY[kind]] += 1
fingerprint = hashlib.sha256(
json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
def _add_plan(product: CanonicalProduct) -> ProductPlan:
return ProductPlan(
kind="add",
canonical=product,
catalog=None,
variant_plans=[
VariantPlan(kind="add", canonical=v, catalog_id=None, file_order=order)
for order, v in enumerate(product.variants, start=1)
],
image_plans=[
ImagePlan(kind="add", source_url=i.source_url, position=i.position,
alt_text=i.alt_text, image_id=None)
for i in product.images
],
)
def _add_detail(plan: ProductPlan) -> dict:
# On add, absent fields fall back to their defaults for display where one
# exists — the detail shows what will actually be set.
set_fields = resolved_product_fields(plan.canonical)
for field_name, default in CLEAR_DEFAULTS.items():
set_fields.setdefault(field_name, default)
return {
"set": {f: _json_safe(v) for f, v in set_fields.items()},
"option_names": list(plan.canonical.option_names),
"variants": [
{"options": list(vp.canonical.options),
"set": _resolved_variant_fields(vp.canonical, vp.file_order)}
for vp in plan.variant_plans
],
"images": [
{"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
for ip in plan.image_plans
],
}
def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]:
"""One walk, two outputs: the resolved-value plan entries and the json-safe
record detail entries are appended side by side, so they can never diverge."""
product_changes: dict[str, object] = {}
changes: list[dict] = []
# Title is always file-present (required header column); "" means the block
# already carries an error and never reaches here.
if product.title and product.title != current.title:
product_changes["title"] = product.title
changes.append(_change("title", current.title, product.title))
for field_name, resolved in resolved_product_fields(product).items():
before = current.fields.get(field_name)
if resolved != before:
product_changes[field_name] = resolved
changes.append(_change(field_name, before, resolved))
for slot in (1, 2, 3):
if f"option{slot}_name" not in product.fields:
continue
before, after = current.option_names[slot - 1], product.option_names[slot - 1]
if after != before:
product_changes[f"option{slot}_name"] = after
changes.append(_change(f"option{slot}_name", before, after))
variant_plans: list[VariantPlan] = []
variant_entries: list[dict] = []
by_options = {v.options: v for v in current.variants}
for file_order, variant in enumerate(product.variants, start=1):
match = by_options.get(variant.options)
if match is None:
variant_plans.append(
VariantPlan(kind="add", canonical=variant, catalog_id=None, file_order=file_order)
)
variant_entries.append(
{"options": list(variant.options), "kind": "add",
"set": _resolved_variant_fields(variant, file_order)}
)
continue
variant_changes: dict[str, object] = {}
variant_change_entries: list[dict] = []
for f, resolved in resolved_variant_fields(variant, file_order).items():
# position lives on the catalog variant as an attribute, not in
# fields{} — compare it explicitly (as images do for theirs).
before = match.position if f == "position" else match.fields.get(f)
if resolved != before:
variant_changes[f] = resolved
variant_change_entries.append(_change(f, before, resolved))
if variant_changes:
variant_plans.append(
VariantPlan(kind="update", canonical=variant, catalog_id=match.id,
file_order=file_order, changes=variant_changes)
)
variant_entries.append(
{"options": list(variant.options), "kind": "update", "changes": variant_change_entries}
)
image_plans: list[ImagePlan] = []
image_entries: list[dict] = []
by_src = {i.source_url: i for i in current.images}
for image in product.images:
match = by_src.get(image.source_url)
if match is None:
image_plans.append(
ImagePlan(kind="add", source_url=image.source_url, position=image.position,
alt_text=image.alt_text, image_id=None)
)
image_entries.append(
{"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text}
)
continue
image_changes: dict[str, object] = {}
image_change_entries: list[dict] = []
for f, before, after in (
("position", match.position, image.position),
("alt_text", match.alt_text, image.alt_text),
):
if after != before:
image_changes[f] = after
image_change_entries.append(_change(f, before, after))
if image_changes:
image_plans.append(
ImagePlan(kind="update", source_url=image.source_url, position=image.position,
alt_text=image.alt_text, image_id=match.id, changes=image_changes)
)
image_entries.append(
{"src": image.source_url, "kind": "update", "changes": image_change_entries}
)
detail: dict = {}
if changes:
detail["changes"] = changes
if variant_entries:
detail["variants"] = variant_entries
if image_entries:
detail["images"] = image_entries
kind = "update" if detail else "unchanged"
return (
ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes,
variant_plans=variant_plans, image_plans=image_plans),
detail,
)
def resolved_fields(fields: dict[str, object]) -> dict[str, object]:
"""File-present fields with None (an explicit clear) resolved to the default."""
return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()}
def resolved_product_fields(product: CanonicalProduct) -> dict[str, object]:
"""Resolved product-level fields, minus the option-name presence markers."""
return {
f: v for f, v in resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS
}
def resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
"""File-present variant fields with clears resolved. A cleared position has
no CLEAR_DEFAULTS entry — it resets to the variant's 1-based file order
within its product ("defaults to file order"), never to NULL."""
resolved = resolved_fields(variant.fields)
if "position" in resolved and resolved["position"] is None:
resolved["position"] = file_order
return resolved
def _resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
return {f: _json_safe(v) for f, v in resolved_variant_fields(variant, file_order).items()}
def _change(field_name: str, before: object, after: object) -> dict:
return {"field": field_name, "before": _json_safe(before), "after": _json_safe(after)}
def _json_safe(value: object) -> object:
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (tuple, list)):
return [_json_safe(v) for v in value]
return value
+41
View File
@@ -0,0 +1,41 @@
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
from __future__ import annotations
class ProductsError(Exception):
"""Base for products-domain errors."""
class FileRejected(ProductsError):
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
file_too_large)."""
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
class DraftNotFound(ProductsError):
"""No such draft for this storefront (or already discarded)."""
class DraftExpired(ProductsError):
"""The draft's validity window passed (§6.3 ~1 h)."""
class PreviewStale(ProductsError):
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
class NothingToApply(ProductsError):
"""PUC-10: no adds and no updates — confirming would be a no-op."""
class RunNotFound(ProductsError):
"""No such import run for this storefront."""
class EmptyCatalog(ProductsError):
"""PUC-9: nothing to export (no products, or none matching the status filter)."""
+121
View File
@@ -0,0 +1,121 @@
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
from __future__ import annotations
from dataclasses import dataclass, field
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
# validation all read from this registry.
PRODUCT_COLUMNS: dict[str, str] = {
# column -> product field name
"Title": "title",
"Description": "description_html",
"Vendor": "vendor",
"Type": "product_type",
"Google Product Category": "google_product_category",
"Tags": "tags",
"Status": "status",
"Published": "published",
"Option1 Name": "option1_name",
"Option2 Name": "option2_name",
"Option3 Name": "option3_name",
}
VARIANT_COLUMNS: dict[str, str] = {
"Variant SKU": "sku",
"Variant Barcode": "barcode",
"Variant Price": "price",
"Variant Cost": "cost",
"Variant Weight": "weight",
"Variant Weight Unit": "weight_unit",
"Variant Volume": "volume",
"Variant Volume Unit": "volume_unit",
"Variant Tax ID 1": "tax_id_1",
"Variant Tax ID 2": "tax_id_2",
"Variant Inventory Tracker": "inventory_tracker",
"Variant Inventory Qty": "inventory_qty",
"Variant Position": "position",
"Variant Image": "variant_image",
}
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
COMPONENT_COLUMNS = tuple(
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
)
KNOWN_COLUMNS = (
{"Handle"}
| set(PRODUCT_COLUMNS)
| set(VARIANT_COLUMNS)
| set(OPTION_VALUE_COLUMNS)
| set(IMAGE_COLUMNS)
| set(COMPONENT_COLUMNS)
)
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
CLEAR_DEFAULTS: dict[str, object] = {
"status": "active",
"published": True,
"product_type": "standalone",
"tags": [],
}
MAX_DATA_ROWS = 5_000 # INV-18
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
@dataclass(frozen=True)
class Row:
"""One CSV data row: 1-based file line number + the cells of known columns
present in the header (column name -> raw string, possibly empty)."""
line_number: int
cells: dict[str, str]
@dataclass(frozen=True)
class ParsedFile:
dialect: str
header: list[str]
unknown_columns: list[str]
rows: list[Row]
@dataclass(frozen=True)
class RowError:
line_number: int
column: str | None
message: str
def as_json(self) -> dict:
return {"line": self.line_number, "column": self.column, "message": self.message}
@dataclass
class CanonicalVariant:
line_number: int
options: tuple[str | None, str | None, str | None]
# field name -> normalized value; present only for columns in the file.
# value None == clear (reset to default/NULL).
fields: dict[str, object] = field(default_factory=dict)
@dataclass
class CanonicalImage:
line_number: int
source_url: str
position: int
alt_text: str | None
@dataclass
class CanonicalProduct:
first_line: int
handle: str
title: str # "" when missing (the block then carries an error)
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
variants: list[CanonicalVariant] = field(default_factory=list)
images: list[CanonicalImage] = field(default_factory=list)
errors: list[RowError] = field(default_factory=list)
@property
def valid(self) -> bool:
return not self.errors
+496
View File
@@ -0,0 +1,496 @@
"""products repo — the SQL layer for the import spine (SD-0002 §6.3 data model).
Owns SQL only: the catalog snapshot the diff engine reads, import draft/run
CRUD, and the apply primitives the confirm transaction calls. Business rules
live in service.py and diff.py — nothing here validates, diffs, commits, or
rolls back (the confirm flow runs the apply primitives inside its own
transaction). Every catalog/draft/run query is storefront-scoped (INV-14).
Dict payload conventions: functions feeding §6.4 API payloads (insert_draft,
list_runs, get_run) return datetimes as `.isoformat()` strings; get_draft_row
returns raw datetimes for the service's expiry check. TEXT[] columns bind/load
as Python lists and NUMERIC loads as Decimal natively under psycopg 3.
"""
from __future__ import annotations
import psycopg
from psycopg import sql
from psycopg.types.json import Jsonb
from .diff import CatalogImage, CatalogProduct, CatalogVariant
# ---------------------------------------------------------------------------
# Catalog snapshot (diff input) + dashboard counts
# ---------------------------------------------------------------------------
def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, CatalogProduct]:
"""The storefront's full catalog, keyed by handle, in diff.py's snapshot shape."""
catalog: dict[str, CatalogProduct] = {}
by_id: dict[int, CatalogProduct] = {}
for row in conn.execute(
"SELECT id, handle, title, description_html, vendor, product_type,"
" google_product_category, tags, status, published,"
" option1_name, option2_name, option3_name"
" FROM product WHERE storefront_id = %s",
(storefront_id,),
):
product = CatalogProduct(
id=row[0],
handle=row[1],
title=row[2],
option_names=(row[10], row[11], row[12]),
fields={
"title": row[2],
"description_html": row[3],
"vendor": row[4],
"product_type": row[5],
"google_product_category": row[6],
"tags": row[7],
"status": row[8],
"published": row[9],
},
variants=[],
images=[],
)
catalog[product.handle] = product
by_id[product.id] = product
for row in conn.execute(
"SELECT v.product_id, v.id, v.position,"
" v.option1_value, v.option2_value, v.option3_value,"
" v.sku, v.barcode, v.price, v.cost, v.weight, v.weight_unit,"
" v.volume, v.volume_unit, v.tax_id_1, v.tax_id_2,"
" v.inventory_tracker, v.inventory_qty, i.source_url"
" FROM variant v"
" JOIN product p ON p.id = v.product_id"
" LEFT JOIN product_image i ON i.id = v.image_id"
" WHERE p.storefront_id = %s"
" ORDER BY v.product_id, v.position, v.id",
(storefront_id,),
):
by_id[row[0]].variants.append(
CatalogVariant(
id=row[1],
options=(row[3], row[4], row[5]),
position=row[2],
fields={
"sku": row[6],
"barcode": row[7],
"price": row[8],
"cost": row[9],
"weight": row[10],
"weight_unit": row[11],
"volume": row[12],
"volume_unit": row[13],
"tax_id_1": row[14],
"tax_id_2": row[15],
"inventory_tracker": row[16],
"inventory_qty": row[17],
"variant_image": row[18],
},
)
)
for row in conn.execute(
"SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text"
" FROM product_image i"
" JOIN product p ON p.id = i.product_id"
" WHERE p.storefront_id = %s"
" ORDER BY i.product_id, i.position, i.id",
(storefront_id,),
):
by_id[row[0]].images.append(
CatalogImage(id=row[1], source_url=row[2], position=row[3], alt_text=row[4])
)
return catalog
_EXPORT_STATUSES = ("all", "active", "draft", "archived")
def export_catalog(
conn: psycopg.Connection, storefront_id: int, status_filter: str
) -> list[CatalogProduct]:
"""The storefront's catalog as an ordered snapshot list, optionally filtered
by product status (PUC-9). Reuses load_catalog's snapshot builder; the
catalog fits in memory (≤5k rows, INV-18). Ordered by handle for a stable,
deterministic export."""
catalog = load_catalog(conn, storefront_id)
products = sorted(catalog.values(), key=lambda p: p.handle)
if status_filter and status_filter != "all":
products = [p for p in products if p.fields.get("status") == status_filter]
return products
def product_count(conn: psycopg.Connection, storefront_id: int) -> int:
return conn.execute(
"SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
).fetchone()[0]
def image_problem_count(conn: psycopg.Connection, storefront_id: int) -> int:
return conn.execute(
"SELECT count(*) FROM product_image i"
" JOIN product p ON p.id = i.product_id"
" WHERE p.storefront_id = %s"
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')",
(storefront_id,),
).fetchone()[0]
def latest_run_id(conn: psycopg.Connection, storefront_id: int) -> int | None:
row = conn.execute(
"SELECT id FROM import_run WHERE storefront_id = %s"
" ORDER BY created_at DESC, id DESC LIMIT 1",
(storefront_id,),
).fetchone()
return row[0] if row else None
# ---------------------------------------------------------------------------
# Import drafts (preview server side, INV-11)
# ---------------------------------------------------------------------------
def insert_draft(
conn: psycopg.Connection,
storefront_id: int,
account_id: int,
file_name: str,
dialect: str,
file_bytes: bytes,
summary: dict,
records: list,
fingerprint: str,
unknown_columns: list[str],
) -> dict:
"""Create a draft (expires in 1 hour); returns the §6.4 draft payload."""
row = conn.execute(
"INSERT INTO import_draft"
" (storefront_id, account_id, file_name, dialect, file_bytes,"
" summary, records, fingerprint, unknown_columns, expires_at)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, now() + interval '1 hour')"
" RETURNING id, expires_at",
(
storefront_id,
account_id,
file_name,
dialect,
file_bytes,
Jsonb(summary),
Jsonb(records),
fingerprint,
unknown_columns,
),
).fetchone()
return {
"id": row[0],
"file_name": file_name,
"dialect": dialect,
"summary": summary,
"unknown_columns": unknown_columns,
"expires_at": row[1].isoformat(),
}
def get_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict | None:
row = conn.execute(
"SELECT id, storefront_id, account_id, file_name, dialect, file_bytes,"
" summary, records, fingerprint, unknown_columns, expires_at, created_at"
" FROM import_draft WHERE id = %s AND storefront_id = %s",
(draft_id, storefront_id),
).fetchone()
if row is None:
return None
columns = (
"id",
"storefront_id",
"account_id",
"file_name",
"dialect",
"file_bytes",
"summary",
"records",
"fingerprint",
"unknown_columns",
"expires_at",
"created_at",
)
record = dict(zip(columns, row))
# BYTEA loads as memoryview; the service expects bytes.
record["file_bytes"] = bytes(record["file_bytes"])
return record
def draft_records(
conn: psycopg.Connection,
storefront_id: int,
draft_id: int,
kind: str | None,
limit: int,
offset: int,
) -> list[dict]:
"""The draft's preview records, order-preserving, optionally filtered by kind."""
rows = conn.execute(
"SELECT rec FROM import_draft d,"
" jsonb_array_elements(d.records) WITH ORDINALITY AS r(rec, ord)"
" WHERE d.id = %(draft_id)s AND d.storefront_id = %(storefront_id)s"
" AND (%(kind)s::text IS NULL OR rec->>'kind' = %(kind)s)"
" ORDER BY ord LIMIT %(limit)s OFFSET %(offset)s",
{
"draft_id": draft_id,
"storefront_id": storefront_id,
"kind": kind,
"limit": limit,
"offset": offset,
},
).fetchall()
return [row[0] for row in rows]
def delete_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
conn.execute(
"DELETE FROM import_draft WHERE id = %s AND storefront_id = %s",
(draft_id, storefront_id),
)
def sweep_expired_drafts(conn: psycopg.Connection) -> None:
conn.execute("DELETE FROM import_draft WHERE expires_at < now()")
# ---------------------------------------------------------------------------
# Import runs (history, PUC-8)
# ---------------------------------------------------------------------------
_TERMINAL_RUN_STATUSES = ("complete", "complete_with_problems")
def insert_run(
conn: psycopg.Connection,
storefront_id: int,
account_id: int,
file_name: str,
dialect: str,
added: int,
updated: int,
errored: int,
status: str,
) -> int:
return conn.execute(
"INSERT INTO import_run"
" (storefront_id, account_id, file_name, dialect,"
" products_added, products_updated, rows_errored, status, completed_at)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END)"
" RETURNING id",
(
storefront_id,
account_id,
file_name,
dialect,
added,
updated,
errored,
status,
status in _TERMINAL_RUN_STATUSES,
),
).fetchone()[0]
def insert_run_errors(conn: psycopg.Connection, run_id: int, errors: list[dict]) -> None:
"""Record per-row errors (RowError.as_json shape: line/column/message)."""
if not errors:
return
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO import_run_error (run_id, line_number, column_name, message)"
" VALUES (%s, %s, %s, %s)",
[(run_id, e["line"], e["column"], e["message"]) for e in errors],
)
_RUN_SELECT = (
"SELECT r.id, r.file_name, r.dialect, r.created_at, r.completed_at, r.status,"
" a.email, r.products_added, r.products_updated, r.rows_errored"
" FROM import_run r JOIN account a ON a.id = r.account_id"
)
def _run_dict(row: tuple) -> dict:
return {
"id": row[0],
"file_name": row[1],
"dialect": row[2],
"created_at": row[3].isoformat(),
"completed_at": row[4].isoformat() if row[4] is not None else None,
"status": row[5],
"by": row[6],
"products_added": row[7],
"products_updated": row[8],
"rows_errored": row[9],
}
def list_runs(
conn: psycopg.Connection, storefront_id: int, limit: int, offset: int
) -> list[dict]:
rows = conn.execute(
_RUN_SELECT
+ " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
" LIMIT %s OFFSET %s",
(storefront_id, limit, offset),
).fetchall()
return [_run_dict(row) for row in rows]
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None:
row = conn.execute(
_RUN_SELECT + " WHERE r.storefront_id = %s AND r.id = %s",
(storefront_id, run_id),
).fetchone()
if row is None:
return None
run = _run_dict(row)
run["errors"] = [
{"line": line, "column": column, "message": message}
for line, column, message in conn.execute(
"SELECT line_number, column_name, message FROM import_run_error"
" WHERE run_id = %s ORDER BY line_number, id",
(run_id,),
)
]
# SLICE-7 fills these; the §6.4 payload shape is stable from SLICE-5 on.
run["image_progress"] = {"done": 0, "total": 0}
run["image_outcomes"] = []
return run
# ---------------------------------------------------------------------------
# Apply primitives — called inside the confirm transaction (Task 8); no commits.
# ---------------------------------------------------------------------------
def insert_product(
conn: psycopg.Connection,
storefront_id: int,
handle: str,
resolved_fields: dict,
option_names: tuple[str | None, str | None, str | None],
) -> int:
"""INSERT with only the file-present fields; absent ones take column defaults."""
columns = ["storefront_id", "handle", "option1_name", "option2_name", "option3_name"]
values: list[object] = [storefront_id, handle, *option_names]
for field_name, value in resolved_fields.items():
columns.append(field_name)
values.append(value)
query = sql.SQL("INSERT INTO product ({}) VALUES ({}) RETURNING id").format(
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
)
return conn.execute(query, values).fetchone()[0]
def update_product(conn: psycopg.Connection, product_id: int, changed_fields: dict) -> None:
if not changed_fields:
return
assignments = [
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
for f in changed_fields
]
query = sql.SQL("UPDATE product SET {}, updated_at = now() WHERE id = {}").format(
sql.SQL(", ").join(assignments), sql.Placeholder()
)
conn.execute(query, [*changed_fields.values(), product_id])
def insert_variant(
conn: psycopg.Connection,
product_id: int,
position: int,
options: tuple[str | None, str | None, str | None],
resolved_fields: dict,
image_id: int | None,
) -> int:
# variant_image is not a column — the caller translates it to image_id; position
# is the explicit param. Filter both defensively.
fields = {
k: v for k, v in resolved_fields.items() if k != "variant_image" and k != "position"
}
columns = [
"product_id",
"position",
"option1_value",
"option2_value",
"option3_value",
"image_id",
]
values: list[object] = [product_id, position, *options, image_id]
for field_name, value in fields.items():
columns.append(field_name)
values.append(value)
query = sql.SQL("INSERT INTO variant ({}) VALUES ({}) RETURNING id").format(
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
)
return conn.execute(query, values).fetchone()[0]
def update_variant(
conn: psycopg.Connection,
variant_id: int,
changed_fields: dict,
image_id: int | None | type(...) = ...,
) -> None:
"""Dynamic UPDATE; image_id's Ellipsis default means "don't touch image_id"."""
fields = {
k: v for k, v in changed_fields.items() if k != "variant_image"
}
assignments = [
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) for f in fields
]
values: list[object] = list(fields.values())
if image_id is not ...:
assignments.append(sql.SQL("image_id = {}").format(sql.Placeholder()))
values.append(image_id)
if not assignments:
return
query = sql.SQL("UPDATE variant SET {}, updated_at = now() WHERE id = {}").format(
sql.SQL(", ").join(assignments), sql.Placeholder()
)
conn.execute(query, [*values, variant_id])
def get_or_create_image(
conn: psycopg.Connection,
product_id: int,
source_url: str,
position: int,
alt_text: str | None,
run_id: int,
) -> int:
"""Image identity within a product is source_url (§6.3); existing rows are
returned untouched — diff emits explicit image update entries for position/alt."""
row = conn.execute(
"SELECT id FROM product_image WHERE product_id = %s AND source_url = %s",
(product_id, source_url),
).fetchone()
if row is not None:
return row[0]
return conn.execute(
"INSERT INTO product_image (product_id, source_url, position, alt_text, import_run_id)"
" VALUES (%s, %s, %s, %s, %s) RETURNING id",
(product_id, source_url, position, alt_text, run_id),
).fetchone()[0]
def update_image(conn: psycopg.Connection, image_id: int, changed_fields: dict) -> None:
"""Subset of {position, alt_text}."""
if not changed_fields:
return
assignments = [
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
for f in changed_fields
]
query = sql.SQL("UPDATE product_image SET {} WHERE id = {}").format(
sql.SQL(", ").join(assignments), sql.Placeholder()
)
conn.execute(query, [*changed_fields.values(), image_id])
+6
View File
@@ -0,0 +1,6 @@
Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text
moon-mug,Moon Mug,"<p>A ceramic mug glazed in moonlight grey.</p>",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk
star-tee,Star Tee,"<p>Soft cotton tee with a hand-printed star.</p>",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay
star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,,
star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,,
star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print
1 Handle Title Description Vendor Type Google Product Category Tags Status Published Option1 Name Option1 Value Option2 Name Option2 Value Variant SKU Variant Price Variant Inventory Qty Image Src Image Position Image Alt Text
2 moon-mug Moon Mug <p>A ceramic mug glazed in moonlight grey.</p> Wiggle Goods standalone Home & Garden > Kitchen & Dining kitchen, mugs active TRUE WG-MUG-001 18.00 40 https://images.example.com/moon-mug.jpg 1 Moon Mug on a desk
3 star-tee Star Tee <p>Soft cotton tee with a hand-printed star.</p> Wiggle Goods standalone Apparel & Accessories > Clothing apparel, tees active TRUE Size S Color Indigo WG-TEE-S 24.00 12 https://images.example.com/star-tee.jpg 1 Star Tee flat lay
4 star-tee M Indigo WG-TEE-M 24.00 18
5 star-tee L Indigo WG-TEE-L 26.00 9
6 star-tee https://images.example.com/star-tee-back.jpg 2 Star Tee back print
+119
View File
@@ -0,0 +1,119 @@
"""Canonical serializer — CatalogProduct snapshot → canonical CSV (SD-0002 §6.5.5).
The export half of "one codec, two directions": this writes exactly the columns
codec.py/validate.py parse, in a row grammar (§6.5.1) the validator regroups
identically — so re-importing an unmodified export diffs to nothing (INV-12).
DB-free, like diff.py: it consumes the same CatalogProduct snapshot the diff
engine reads (repo.load_catalog), and the service streams the result.
"""
from __future__ import annotations
import csv
import io
from collections.abc import Iterable, Iterator
from decimal import Decimal
from .diff import CatalogProduct
from .models import (
IMAGE_COLUMNS,
OPTION_VALUE_COLUMNS,
PRODUCT_COLUMNS,
VARIANT_COLUMNS,
)
# Canonical column order: Handle, then product, option-value, variant, image
# columns. A superset of everything the parser knows (models.KNOWN_COLUMNS minus
# the #15-reserved Component columns, which export never emits).
HEADER: list[str] = [
"Handle",
*PRODUCT_COLUMNS,
*OPTION_VALUE_COLUMNS,
*VARIANT_COLUMNS,
*IMAGE_COLUMNS,
]
# field name -> column name, inverting the model registry (the parser reads
# column->field; the serializer writes field->column).
_PRODUCT_FIELD_TO_COL = {field: col for col, field in PRODUCT_COLUMNS.items()}
_VARIANT_FIELD_TO_COL = {field: col for col, field in VARIANT_COLUMNS.items()}
def _cell(value: object) -> str:
"""Serialize one value to its canonical cell text (the parse inverse)."""
if value is None:
return ""
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, Decimal):
return str(value)
if isinstance(value, (list, tuple)):
return ", ".join(str(v) for v in value)
return str(value)
def catalog_to_csv(products: Iterable[CatalogProduct]) -> Iterator[str]:
"""Stream canonical CSV text, header first, one product block at a time."""
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(HEADER)
yield _drain(buf)
for product in products:
for row in _product_rows(product):
writer.writerow([row.get(col, "") for col in HEADER])
yield _drain(buf)
def _drain(buf: io.StringIO) -> str:
text = buf.getvalue()
buf.seek(0)
buf.truncate(0)
return text
def _product_rows(product: CatalogProduct) -> list[dict[str, str]]:
"""The product's CSV rows (§6.5.1 grammar): product fields + option names on
the first row; one variant per row; images interleaved; image-only rows when
a product has more images than variants."""
base = {"Handle": product.handle, "Title": product.title}
for field, col in _PRODUCT_FIELD_TO_COL.items():
if field in product.fields:
base[col] = _cell(product.fields[field])
for slot, name in enumerate(product.option_names, start=1):
if name:
base[f"Option{slot} Name"] = name
count = max(len(product.variants), len(product.images))
rows: list[dict[str, str]] = []
for i in range(count):
# Row 0 carries the product-level fields; later rows carry only Handle.
row = dict(base) if i == 0 else {"Handle": product.handle}
if i < len(product.variants):
_write_variant(row, product, product.variants[i])
if i < len(product.images):
_write_image(row, product.images[i])
rows.append(row)
return rows
def _write_image(row: dict[str, str], image) -> None:
row["Image Src"] = image.source_url
row["Image Position"] = str(image.position)
if image.alt_text is not None:
row["Image Alt Text"] = image.alt_text
def _write_variant(row: dict[str, str], product: CatalogProduct, variant) -> None:
"""Fill a row's option-value + variant columns for one variant."""
for slot, value in enumerate(variant.options, start=1):
# Only emit an option value when the product actually has that option
# (a no-option product's single variant carries all-NULL options).
if product.option_names[slot - 1] and value is not None:
row[f"Option{slot} Value"] = value
# position is a CatalogVariant attribute, not a fields{} entry — emit it
# explicitly. An empty Variant Position cell re-imports as "reset to file
# order", so a non-sequential stored position would round-trip to an update
# (INV-12). (Like _write_image, which emits its position attribute.)
row["Variant Position"] = str(variant.position)
for field, col in _VARIANT_FIELD_TO_COL.items():
if field in variant.fields:
row[col] = _cell(variant.fields[field])
+263
View File
@@ -0,0 +1,263 @@
"""products service — the import/export use-case orchestration (SD-0002 §6.5).
Coordinates codec → validate → diff → repo; owns transaction boundaries (repo
never commits). Preview is read-only against catalog tables (INV-11): validation
writes exactly one row — the import_draft. TEL events per §9.1.
"""
from __future__ import annotations
import time
from collections.abc import Iterator
from datetime import datetime, timezone
import psycopg
from app.platform import telemetry
from . import codec, diff, repo, serialize, validate
from .errors import (
DraftExpired,
DraftNotFound,
EmptyCatalog,
NothingToApply,
PreviewStale,
RunNotFound,
)
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
file_name: str, data: bytes) -> dict:
"""Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected."""
started = time.monotonic()
# Commit the sweep before parsing: a FileRejected mid-parse must not roll
# back expired-draft cleanup along with it.
repo.sweep_expired_drafts(conn)
conn.commit()
parsed = codec.parse_csv(data)
products = validate.build_products(parsed)
catalog = repo.load_catalog(conn, storefront_id)
diff_result = diff.compute_diff(catalog, products)
draft = repo.insert_draft(
conn, storefront_id, account_id, file_name, parsed.dialect, data,
diff_result.summary, diff_result.records, diff_result.fingerprint,
parsed.unknown_columns,
)
conn.commit()
telemetry.emit(
"import_draft_created",
storefront_id=storefront_id,
dialect=parsed.dialect,
row_count=len(parsed.rows),
adds=diff_result.summary["adds"],
updates=diff_result.summary["updates"],
unchanged=diff_result.summary["unchanged"],
errors=diff_result.summary["errors"],
unknown_columns_count=len(parsed.unknown_columns),
duration_ms=int((time.monotonic() - started) * 1000),
)
return draft
def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
"""The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3)."""
row = repo.get_draft_row(conn, storefront_id, draft_id)
if row is None:
raise DraftNotFound()
if row["expires_at"] < datetime.now(timezone.utc):
repo.delete_draft(conn, storefront_id, draft_id)
conn.commit()
raise DraftExpired()
return row
def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
"""The §6.4 draft payload — never file_bytes or the full records list."""
row = _live_draft_row(conn, storefront_id, draft_id)
return {
"id": row["id"],
"file_name": row["file_name"],
"dialect": row["dialect"],
"summary": row["summary"],
"unknown_columns": row["unknown_columns"],
"expires_at": row["expires_at"].isoformat(),
}
def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int,
kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]:
"""The draft's preview records, paged, optionally filtered by kind (PUC-3)."""
_live_draft_row(conn, storefront_id, draft_id)
return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset)
def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
"""Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a)."""
repo.delete_draft(conn, storefront_id, draft_id)
conn.commit()
def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int,
draft_id: int) -> int:
"""Apply the previewed diff in one transaction (PUC-4; INV-10/11).
Everything is re-derived from the draft's stored file bytes against the live
catalog; a fingerprint mismatch means the catalog drifted since preview
(PreviewStale — the draft is kept so the merchant can re-validate). The apply
executes the typed plan compute_diff built alongside the preview records, so
what lands is exactly what the preview showed. rows_errored counts the
import_run_error rows recorded (one per RowError), which is what the run
detail's error table shows; the preview's errors tile counts error *products*.
"""
started = time.monotonic()
row = _live_draft_row(conn, storefront_id, draft_id)
parsed = codec.parse_csv(row["file_bytes"])
products = validate.build_products(parsed)
catalog = repo.load_catalog(conn, storefront_id)
diff_result = diff.compute_diff(catalog, products)
if diff_result.fingerprint != row["fingerprint"]:
# Release the read snapshot; nothing written.
conn.rollback()
raise PreviewStale()
summary_counts = diff_result.summary
if summary_counts["adds"] + summary_counts["updates"] == 0:
# Release the read snapshot; nothing written.
conn.rollback()
raise NothingToApply()
error_rows = [
error.as_json()
for plan in diff_result.plan if plan.kind == "error"
for error in plan.canonical.errors
]
try:
run_id = repo.insert_run(
conn, storefront_id, account_id, row["file_name"], row["dialect"],
added=summary_counts["adds"], updated=summary_counts["updates"],
errored=len(error_rows), status="complete",
)
for plan in diff_result.plan:
_apply_product_plan(conn, storefront_id, plan, run_id)
repo.insert_run_errors(conn, run_id, error_rows)
repo.delete_draft(conn, storefront_id, draft_id)
conn.commit()
except Exception as exc:
conn.rollback()
telemetry.emit(
"import_apply_failed",
draft_id=draft_id,
storefront_id=storefront_id,
error_class=type(exc).__name__,
)
raise
telemetry.emit(
"import_run_completed",
run_id=run_id,
storefront_id=storefront_id,
added=summary_counts["adds"],
updated=summary_counts["updates"],
errored=len(error_rows),
duration_ms=int((time.monotonic() - started) * 1000),
)
return run_id
def _apply_product_plan(conn: psycopg.Connection, storefront_id: int,
plan: diff.ProductPlan, run_id: int) -> None:
"""Execute one product's plan inside the confirm transaction (no commits here)."""
if plan.kind == "add":
# Title is a canonical attribute, not a fields{} entry — non-error
# products always carry one (validate guarantees it).
product_fields = {"title": plan.canonical.title}
product_fields.update(diff.resolved_product_fields(plan.canonical))
product_id = repo.insert_product(
conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names
)
image_ids: dict[str, int] = {}
elif plan.kind == "update":
product_id = plan.catalog.id
repo.update_product(conn, product_id, plan.product_changes)
image_ids = {image.source_url: image.id for image in plan.catalog.images}
else:
return
# Images first, so variants' variant_image URLs resolve to ids: validate puts
# every variant_image URL into canonical.images, so each URL is in either the
# catalog map (existing image) or the adds below.
for image_plan in plan.image_plans:
if image_plan.kind == "add":
image_ids[image_plan.source_url] = repo.get_or_create_image(
conn, product_id, image_plan.source_url, image_plan.position,
image_plan.alt_text, run_id,
)
else:
repo.update_image(conn, image_plan.image_id, image_plan.changes)
for variant_plan in plan.variant_plans:
if variant_plan.kind == "add":
fields = diff.resolved_variant_fields(variant_plan.canonical, variant_plan.file_order)
# diff time resolved any cleared position to file order; the
# file_order fallback covers an absent position column.
position = fields.get("position") or variant_plan.file_order
url = fields.get("variant_image")
image_id = image_ids[url] if url else None
repo.insert_variant(
conn, product_id, position, variant_plan.canonical.options, fields, image_id
)
elif "variant_image" in variant_plan.changes:
url = variant_plan.changes["variant_image"]
repo.update_variant(
conn, variant_plan.catalog_id, variant_plan.changes,
image_id=image_ids[url] if url else None,
)
else:
repo.update_variant(conn, variant_plan.catalog_id, variant_plan.changes)
def list_runs(conn: psycopg.Connection, storefront_id: int,
limit: int = 50, offset: int = 0) -> list[dict]:
"""The storefront's import history, newest first (PUC-8)."""
return repo.list_runs(conn, storefront_id, limit, offset)
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
"""One run's §6.4 detail payload, errors included."""
run = repo.get_run(conn, storefront_id, run_id)
if run is None:
raise RunNotFound()
return run
def export_catalog(
conn: psycopg.Connection, storefront_id: int, status_filter: str
) -> Iterator[str]:
"""Stream the storefront's catalog as canonical CSV (PUC-9; INV-12 codec).
Read-only: builds the snapshot, then streams the serializer over it. TEL-3
is emitted once the stream is exhausted, with the product count and elapsed
time. Raises EmptyCatalog before yielding anything if the (filtered) catalog
is empty, so the BFF can answer 409 cleanly with no partial body.
"""
started = time.monotonic()
snapshot = repo.export_catalog(conn, storefront_id, status_filter)
if not snapshot:
raise EmptyCatalog()
def _stream() -> Iterator[str]:
yield from serialize.catalog_to_csv(snapshot)
telemetry.emit(
"catalog_exported",
storefront_id=storefront_id,
status_filter=status_filter,
product_count=len(snapshot),
duration_ms=int((time.monotonic() - started) * 1000),
)
return _stream()
def summary(conn: psycopg.Connection, storefront_id: int) -> dict:
"""The products dashboard counts (§6.4)."""
return {
"product_count": repo.product_count(conn, storefront_id),
"image_problem_count": repo.image_problem_count(conn, storefront_id),
"latest_run_id": repo.latest_run_id(conn, storefront_id),
}
+299
View File
@@ -0,0 +1,299 @@
"""Row validation — ParsedFile rows → canonical products + row errors (SD-0002 §6.5.1).
The codec (codec.py) handles file-level gates; this module is the row-semantics
half of the PUC-5 import spine. It groups consecutive rows sharing a Handle into
product blocks (Shopify's grammar), normalizes product/variant/image fields, and
records every rule violation as a merchant-language RowError. Errors never raise:
an error poisons its whole product block (the product previews as kind="error"
and is excluded from apply) while parsing continues so the merchant gets a
complete accounting in one pass (BUC-1a). Description HTML is sanitized with nh3
on the way in (INV-15).
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
import nh3
from .models import (
COMPONENT_COLUMNS,
OPTION_VALUE_COLUMNS,
PRODUCT_COLUMNS,
VARIANT_COLUMNS,
CanonicalImage,
CanonicalProduct,
CanonicalVariant,
ParsedFile,
Row,
RowError,
)
_HANDLE_RE = re.compile(r"^[a-z0-9-]+$")
_STATUSES = {"draft", "active", "archived"}
# Title is handled as an attribute, not via fields{}. Option names live in BOTH
# .option_names (the values) and fields{} (the file-presence signal the diff
# needs: absent column == untouched, never a clear).
_ATTRIBUTE_COLUMNS = {"Title"}
# Sentinel: the cell failed normalization (the error is already recorded).
_INVALID = object()
def build_products(parsed: ParsedFile) -> list[CanonicalProduct]:
"""Group rows into product blocks and validate every §6.5.1 rule."""
products: list[CanonicalProduct] = []
closed_handles: set[str] = set()
block: list[Row] = []
def flush() -> None:
nonlocal block
if block:
closed_handles.add(block[0].cells["Handle"])
products.append(_build_block(block))
block = []
for row in parsed.rows:
handle = row.cells.get("Handle", "")
if block and handle == block[0].cells["Handle"]:
block.append(row)
continue
flush()
if not handle:
products.append(
_error_block(row, "(missing)", RowError(row.line_number, "Handle", "a row needs a Handle"))
)
elif not _HANDLE_RE.match(handle):
products.append(
_error_block(
row,
handle,
RowError(
row.line_number,
"Handle",
f"'{handle}' isn't a valid handle — lowercase letters, numbers, and dashes only",
),
)
)
elif handle in closed_handles:
products.append(
_error_block(
row,
handle,
RowError(
row.line_number,
"Handle",
f"rows for '{handle}' must be consecutive — it already appeared earlier in the file",
),
)
)
else:
block = [row]
flush()
return products
def all_errors(products: list[CanonicalProduct]) -> list[RowError]:
return [error for product in products for error in product.errors]
def _error_block(row: Row, handle: str, error: RowError) -> CanonicalProduct:
return CanonicalProduct(first_line=row.line_number, handle=handle, title="", errors=[error])
def _build_block(rows: list[Row]) -> CanonicalProduct:
first = rows[0]
handle = first.cells["Handle"]
errors: list[RowError] = []
title = first.cells.get("Title", "")
if not title:
errors.append(RowError(first.line_number, "Title", f"'{handle}' is missing its Title"))
option_names = tuple(first.cells.get(f"Option{n} Name") or None for n in (1, 2, 3))
has_options = any(option_names)
# Product-level fields come from the first row only; empty cell == clear (None).
fields: dict[str, object] = {}
for column, field_name in PRODUCT_COLUMNS.items():
if column in _ATTRIBUTE_COLUMNS or column not in first.cells:
continue
cell = first.cells[column]
if not cell:
fields[field_name] = None
continue
value = _product_value(first.line_number, column, field_name, cell, errors)
if value is not _INVALID:
fields[field_name] = value
variants: list[CanonicalVariant] = []
images: list[CanonicalImage] = []
seen_combos: set[tuple[str | None, str | None, str | None]] = set()
for index, row in enumerate(rows):
cells = row.cells
line = row.line_number
for column in COMPONENT_COLUMNS:
if cells.get(column):
errors.append(
RowError(line, column, "kits arrive in a coming release — leave the Component columns empty")
)
has_image = bool(cells.get("Image Src"))
if has_image:
_collect_image(row, images, errors)
# A row carries a variant iff any option value / Variant-* cell is filled;
# the first row of a no-option product always carries the single variant.
carries_variant = (
any(cells.get(c) for c in OPTION_VALUE_COLUMNS)
or any(cells.get(c) for c in VARIANT_COLUMNS)
or (index == 0 and not has_options)
)
if carries_variant:
options = tuple(cells.get(c) or None for c in OPTION_VALUE_COLUMNS)
for n in (1, 2, 3):
value, name = options[n - 1], option_names[n - 1]
if value and not name:
errors.append(
RowError(
line,
f"Option{n} Value",
f"Option{n} Value given but the product has no Option{n} Name",
)
)
elif name and not value:
errors.append(
RowError(
line,
f"Option{n} Value",
f"this variant is missing its Option{n} Value ('{name}')",
)
)
if not has_options:
if variants:
errors.append(RowError(line, None, "a product without options can have only one variant"))
elif options in seen_combos:
errors.append(
RowError(line, None, f"duplicate variant — '{handle}' already has a variant with these options")
)
seen_combos.add(options)
variant_fields: dict[str, object] = {}
for column, field_name in VARIANT_COLUMNS.items():
if column not in cells:
continue
cell = cells[column]
if not cell:
variant_fields[field_name] = None
continue
value = _variant_value(line, column, field_name, cell, errors)
if value is _INVALID:
continue
variant_fields[field_name] = value
if field_name == "variant_image" and cell not in {i.source_url for i in images}:
images.append(
CanonicalImage(line_number=line, source_url=cell, position=len(images) + 1, alt_text=None)
)
variants.append(CanonicalVariant(line_number=line, options=options, fields=variant_fields))
elif index > 0 and not has_image:
errors.append(RowError(line, None, "this row has no variant or image data"))
return CanonicalProduct(
first_line=first.line_number,
handle=handle,
title=title,
option_names=option_names,
fields=fields,
variants=variants,
images=images,
errors=errors,
)
def _collect_image(row: Row, images: list[CanonicalImage], errors: list[RowError]) -> None:
cells = row.cells
source_url = cells["Image Src"]
position_cell = cells.get("Image Position", "")
position: int | None = None
if position_cell:
try:
position = int(position_cell)
if position < 1:
raise ValueError
except ValueError:
position = None
errors.append(RowError(row.line_number, "Image Position", f"'{position_cell}' is not a position"))
# Dedupe by source URL within the block — first occurrence wins.
if source_url in {i.source_url for i in images}:
return
images.append(
CanonicalImage(
line_number=row.line_number,
source_url=source_url,
position=position if position is not None else len(images) + 1,
alt_text=cells.get("Image Alt Text") or None,
)
)
def _product_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
if field_name == "tags":
return [tag.strip() for tag in cell.split(",") if tag.strip()]
if field_name == "status":
status = cell.lower()
if status not in _STATUSES:
errors.append(RowError(line, column, f"'{cell}' is not a status — use draft, active, or archived"))
return _INVALID
return status
if field_name == "published":
flag = cell.upper()
if flag not in ("TRUE", "FALSE"):
errors.append(RowError(line, column, f"'{cell}' is not TRUE or FALSE"))
return _INVALID
return flag == "TRUE"
if field_name == "description_html":
return nh3.clean(cell)
if field_name == "product_type":
if cell != "standalone":
errors.append(RowError(line, column, "kits arrive in a coming release — Type must be 'standalone'"))
return _INVALID
return cell
return cell
def _variant_value(line: int, column: str, field_name: str, cell: str, errors: list[RowError]) -> object:
if field_name in ("price", "cost"):
return _decimal_or_error(line, column, cell, f"'{cell}' is not a price", errors)
if field_name in ("weight", "volume"):
return _decimal_or_error(line, column, cell, f"'{cell}' is not a number", errors)
if field_name == "inventory_qty":
try:
quantity = int(cell)
if quantity < 0:
raise ValueError
except ValueError:
errors.append(RowError(line, column, f"'{cell}' is not a whole number"))
return _INVALID
return quantity
if field_name == "position":
try:
position = int(cell)
if position < 1:
raise ValueError
except ValueError:
errors.append(RowError(line, column, f"'{cell}' is not a position"))
return _INVALID
return position
return cell
def _decimal_or_error(line: int, column: str, cell: str, message: str, errors: list[RowError]) -> object:
try:
value = Decimal(cell)
if not value.is_finite() or value < 0:
raise InvalidOperation
except InvalidOperation:
errors.append(RowError(line, column, message))
return _INVALID
return value
@@ -0,0 +1,19 @@
"""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",
]
+10
View File
@@ -0,0 +1,10 @@
"""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)."""
+10
View File
@@ -0,0 +1,10 @@
"""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
@@ -0,0 +1,56 @@
"""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)
+375 -9
View File
@@ -1,22 +1,118 @@
"""ecomm backend — FastAPI app factory + the REST BFF.
SLICE-1 mounts only /healthz; the auth and storefront endpoints (§6.4) grow here in
later slices. create_app() opens the connection pool and self-migrates the database
at startup (INV-1, INV-7): the app boots against empty persistence and applies its
own schema — there is no seed step.
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.
"""
from __future__ import annotations
import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import psycopg
from fastapi import Depends, FastAPI, Response
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app.domains import accounts, products, storefronts
from app.platform import config, db
from app.platform.deps import get_conn
from app.platform import mailer as mailer_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
def create_app(database_url: str | None = None) -> FastAPI:
# The repo-root VERSION file is the single version source: the deploy pin checks out its
# tag, and /healthz must report it back (flotilla-core's verify gate compares them).
_REPO_ROOT = Path(__file__).resolve().parents[2]
try:
_APP_VERSION = (_REPO_ROOT / "VERSION").read_text().strip()
except OSError:
_APP_VERSION = "0.0.0"
class RequestCodeBody(BaseModel):
email: str
class VerifyBody(BaseModel):
email: str
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).
uvicorn configures only its own loggers, leaving the root with no INFO handler — so
without this the `ecomm.mailer` line (PUC-10's local dev channel: the one-time code in
the backend log) would be swallowed. A dedicated handler with propagate=False keeps it
out of uvicorn's stream and avoids double-logging.
"""
lg = logging.getLogger("ecomm")
if not lg.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s: %(message)s"))
lg.addHandler(handler)
lg.setLevel(logging.INFO)
lg.propagate = False
def _set_session_cookie(response: Response, account: accounts.Account) -> None:
token = session_mod.sign({"account_id": account.id, "email": account.email}, config.session_secret())
response.set_cookie(
SESSION_COOKIE,
token,
httponly=True,
samesite="lax",
secure=config.cookie_secure(),
path="/",
)
def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI:
_ensure_app_logging()
dsn = database_url or config.database_url()
@asynccontextmanager
@@ -24,12 +120,13 @@ def create_app(database_url: str | None = None) -> FastAPI:
app.state.pool = db.open_pool(dsn)
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
try:
yield
finally:
app.state.pool.close()
app = FastAPI(title="ecomm", version="0.1", lifespan=lifespan)
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
@app.get("/healthz")
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
@@ -43,7 +140,276 @@ def create_app(database_url: str | None = None) -> FastAPI:
if pending:
response.status_code = 503
return {"status": "unavailable", "reason": "migrations_pending"}
return {"status": "ok"}
return {"status": "ok", "version": _APP_VERSION}
@app.post("/api/auth/request-code")
def request_code(
body: RequestCodeBody,
conn: psycopg.Connection = Depends(get_conn),
mailer: Mailer = Depends(get_mailer),
):
"""Issue + dispatch a one-time code. Uniform for new/known emails (§6.6)."""
try:
accounts.request_code(conn, mailer, body.email)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.ResendCooldown as exc:
return _error(
429, "resend_cooldown",
f"Please wait {exc.retry_after_s}s before requesting another code.",
retry_after_s=exc.retry_after_s,
)
except accounts.DeliveryFailed:
return _error(
502, "delivery_failed",
"We couldn't send the code — try again in a moment.",
)
return Response(status_code=204)
@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)."""
try:
account, created = accounts.verify(conn, body.email, body.code)
except accounts.InvalidEmail:
return _error(400, "invalid_email", "That doesn't look like an email address.")
except accounts.CodeMismatch as exc:
return _error(
400, "code_mismatch", "That code didn't match.",
attempts_remaining=exc.attempts_remaining,
)
except accounts.CodeExpired:
return _error(400, "code_expired", "That code expired — request a fresh one.")
except accounts.CodeExhausted:
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)
_set_session_cookie(resp, account)
return resp
@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)."""
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)}
@app.post("/api/auth/logout")
def logout():
"""End the session by clearing the cookie. Idempotent (PUC-9; no server state)."""
resp = Response(status_code=204)
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.",
)
return JSONResponse(status_code=201, content={"run_id": run_id})
@app.delete("/api/products/imports/drafts/{draft_id}")
def discard_import_draft(
draft_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Discard the draft, no trace kept; idempotent (§6.4; PUC-3a)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
products.discard_draft(conn, sf.id, draft_id)
return Response(status_code=204)
@app.get("/api/products/imports/runs")
def list_import_runs(
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The storefront's import history, newest first (§6.4; PUC-8)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
return {"runs": products.list_runs(conn, sf.id, limit, offset)}
@app.get("/api/products/imports/runs/{run_id}")
def get_import_run(
run_id: int,
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""One run's detail payload, errors included (§6.4; PUC-8)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
return products.get_run(conn, sf.id, run_id)
except products.RunNotFound:
return _error(404, "not_found", "No such import run.")
@app.get("/api/products/summary")
def products_summary(
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""The products dashboard counts (§6.4)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
return products.summary(conn, sf.id)
@app.get("/api/products/export")
def export_products(
status: str = Query(default="all", pattern="^(all|active|draft|archived)$"),
conn: psycopg.Connection = Depends(get_conn),
sess: dict | None = Depends(get_session),
):
"""Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9)."""
gate = _merchant_gate(conn, sess)
if isinstance(gate, JSONResponse):
return gate
_account, sf = gate
try:
stream = products.export_catalog(conn, sf.id, status)
except products.EmptyCatalog:
return _error(409, "empty_catalog", "There are no products to export.")
return StreamingResponse(
stream,
media_type="text/csv",
headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'},
)
@app.get("/api/products/sample.csv")
def products_sample_csv():
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
return PlainTextResponse(
products.SAMPLE_CSV_PATH.read_text(),
media_type="text/csv",
headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'},
)
# Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the
# backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the
# dist dir doesn't exist (Vite serves the frontend) and the mount is skipped.
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
+51
View File
@@ -17,3 +17,54 @@ _DEFAULT_DATABASE_URL = "postgresql://ecomm:ecomm@localhost:5432/ecomm"
def database_url() -> str:
"""The psycopg DSN for the application database."""
return os.environ.get("ECOMM_DATABASE_URL") or _DEFAULT_DATABASE_URL
# Session signing secret. Dev default is intentionally well-known and insecure — a clean
# checkout works with no setup; deployed environments MUST supply ECOMM_SESSION_SECRET from
# Secret Manager (INV-8, §6.6). Rotating it invalidates all sessions at once (§6.2).
_DEFAULT_SESSION_SECRET = "dev-insecure-session-secret-change-me"
def session_secret() -> str:
"""The HMAC key for signed session cookies (and the one-time-code pepper)."""
return os.environ.get("ECOMM_SESSION_SECRET") or _DEFAULT_SESSION_SECRET
def cookie_secure() -> bool:
"""Whether to mark the session cookie Secure (HTTPS-only). True in deployed envs."""
return os.environ.get("ECOMM_COOKIE_SECURE", "").strip().lower() in {"1", "true", "yes", "on"}
def mailer_kind() -> str:
"""Which mailer adapter to build: 'log' (dev/tests) or 'smtp' (deployed)."""
return os.environ.get("ECOMM_MAILER") or "log"
# SMTP relay coordinates (deployed envs only; INV-8 — host/port/user/from are non-secret
# overlay values, the password is a Secret Manager reference resolved by the deploy).
def smtp_host() -> str:
return os.environ.get("ECOMM_SMTP_HOST", "")
def smtp_port() -> int:
return int(os.environ.get("ECOMM_SMTP_PORT") or "587")
def smtp_user() -> str:
return os.environ.get("ECOMM_SMTP_USER", "")
def smtp_password() -> str:
return os.environ.get("ECOMM_SMTP_PASSWORD", "")
def smtp_from() -> str:
"""The From header; defaults to the relay user."""
return os.environ.get("ECOMM_SMTP_FROM") or smtp_user()
def smtp_starttls() -> bool:
"""STARTTLS on the relay connection (default on; disable only for odd relays)."""
return os.environ.get("ECOMM_SMTP_STARTTLS", "1").strip().lower() not in {"0", "false", "no", "off"}
+22 -2
View File
@@ -1,18 +1,38 @@
"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps).
Yields a pooled connection per request. The pool lives on app.state, built once in
create_app(). Later slices add the current-session and mailer dependencies here.
Yields a pooled connection per request, the process-wide mailer, and the verified session
payload (from the signed cookie). The pool and mailer live on app.state, built once in
create_app(). The storefronts dependency lands in SLICE-3.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
import psycopg
from fastapi import Request
from app.platform import config, session
from app.platform.mailer import Mailer
SESSION_COOKIE = "ecomm_session"
def get_conn(request: Request) -> Iterator[psycopg.Connection]:
"""A pooled connection for the duration of one request."""
pool = request.app.state.pool
with pool.connection() as conn:
yield conn
def get_mailer(request: Request) -> Mailer:
"""The process-wide mailer adapter (built from config at startup, INV-8)."""
return request.app.state.mailer
def get_session(request: Request) -> dict[str, Any] | None:
"""The verified session payload from the cookie, or None if absent/invalid."""
token = request.cookies.get(SESSION_COOKIE)
if not token:
return None
return session.verify(token, config.session_secret())
+98
View File
@@ -0,0 +1,98 @@
"""platform/mailer — the outbound email port (SD-0001 §6.2).
A one-method port `send(to, subject, body)` with two adapters: `LogMailer` (dev/tests —
the message lands in the app log and an in-memory outbox tests read back, §6.8) and the
deployed `SmtpMailer` (relay coordinates from configuration, INV-8). The adapter is chosen
by configuration at startup, so no deployment shape lives in the domain. LogMailer logs the
full body on purpose: that is PUC-10's "local dev channel" — the one-time code reaches the
developer in the terminal. SmtpMailer never logs the body or the recipient (INV-3 / §6.6
log hygiene) and raises MailerError on delivery failure (INV-9 → the §6.4 502).
"""
from __future__ import annotations
import hashlib
import logging
import smtplib
from dataclasses import dataclass
from email.message import EmailMessage
from typing import Protocol
from app.platform import config
logger = logging.getLogger("ecomm.mailer")
class MailerError(Exception):
"""Delivery failed — the relay refused or was unreachable (INV-9)."""
@dataclass(frozen=True)
class SentMessage:
to: str
subject: str
body: str
class Mailer(Protocol):
"""The outbound-message port. Raises on delivery failure (INV-9 → §6.4 502)."""
def send(self, to: str, subject: str, body: str) -> None: ...
class LogMailer:
"""Dev/test adapter: records to an in-memory outbox and logs the message (PUC-10)."""
def __init__(self) -> None:
self.outbox: list[SentMessage] = []
def send(self, to: str, subject: str, body: str) -> None:
self.outbox.append(SentMessage(to=to, subject=subject, body=body))
# Full body incl. the code — the local dev channel. Dev/test only (never deployed).
logger.info("LogMailer -> %s | %s\n%s", to, subject, body)
class SmtpMailer:
"""Deployed adapter: real mail over an SMTP relay (STARTTLS + login by default).
Logs only a hashed recipient — never the address, subject, or body (§6.6).
"""
def __init__(self, host: str, port: int, user: str, password: str, sender: str, starttls: bool) -> None:
self._host, self._port = host, port
self._user, self._password = user, password
self._sender, self._starttls = sender, starttls
def send(self, to: str, subject: str, body: str) -> None:
msg = EmailMessage()
msg["To"] = to
msg["From"] = self._sender
msg["Subject"] = subject
msg.set_content(body)
to_hash = hashlib.sha256(to.encode("utf-8")).hexdigest()[:8]
try:
with smtplib.SMTP(self._host, self._port, timeout=10) as smtp:
if self._starttls:
smtp.starttls()
if self._user:
smtp.login(self._user, self._password)
smtp.send_message(msg)
except Exception as exc:
logger.warning("smtp send FAILED to=%s: %s", to_hash, type(exc).__name__)
raise MailerError(str(exc)) from exc
logger.info("smtp sent to=%s", to_hash)
def build_mailer(kind: str) -> Mailer:
"""Select the mailer adapter by configured kind (config.mailer_kind(), INV-8)."""
if kind == "log":
return LogMailer()
if kind == "smtp":
return SmtpMailer(
host=config.smtp_host(),
port=config.smtp_port(),
user=config.smtp_user(),
password=config.smtp_password(),
sender=config.smtp_from(),
starttls=config.smtp_starttls(),
)
raise ValueError(f"unknown mailer kind: {kind!r}")
+49
View File
@@ -0,0 +1,49 @@
"""platform/session — stateless signed session tokens (SD-0001 §6.2).
Sessions are signed HTTP-only cookies; there is no session table. A token is
`base64url(payload_json).base64url(hmac_sha256(payload))` keyed by the configured session
secret (INV-8). `verify` checks the MAC in constant time and returns the payload dict, or
None for any malformed/tampered/wrong-key token. No server-side state to revoke — a leaked
secret is rotated, invalidating every session at once (§6.2, accepted at MVP scale).
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
from typing import Any
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _b64d(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def _mac(payload_b64: str, secret: str) -> str:
digest = hmac.new(secret.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
return _b64e(digest)
def sign(payload: dict[str, Any], secret: str) -> str:
"""Serialize and sign a session payload into a cookie-safe token."""
payload_b64 = _b64e(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
return f"{payload_b64}.{_mac(payload_b64, secret)}"
def verify(token: str, secret: str) -> dict[str, Any] | None:
"""Return the payload if the token's signature is valid, else None."""
if not token or "." not in token:
return None
payload_b64, _, sig = token.partition(".")
expected = _mac(payload_b64, secret)
if not hmac.compare_digest(sig, expected):
return None
try:
return json.loads(_b64d(payload_b64))
except (ValueError, json.JSONDecodeError):
return None
+13
View File
@@ -0,0 +1,13 @@
"""Structured log-event telemetry (SD-0002 §9.1). One JSON object per event on the
`ecomm.telemetry` logger — counts and durations only; never file names, URLs,
catalog content, or secret bytes (§6.3-handbook)."""
from __future__ import annotations
import json
import logging
_logger = logging.getLogger("ecomm.telemetry")
def emit(event: str, **fields: object) -> None:
_logger.info(json.dumps({"event": event, **fields}, sort_keys=True, default=str))
+130
View File
@@ -0,0 +1,130 @@
-- 0002_products.sql — SD-0002 §6.3 data model (SLICE-5). Forward-only (INV-7):
-- never edit once merged; add a new numbered migration.
-- product — one catalog product per (storefront, handle) (INV-13/14). Option *names*
-- live here; product_type's kit values are schema-open but a service-layer rule
-- rejects non-'standalone' until #15 (same pattern as INV-4).
CREATE TABLE product (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
handle TEXT NOT NULL,
title TEXT NOT NULL,
description_html TEXT,
vendor TEXT,
product_type TEXT NOT NULL DEFAULT 'standalone'
CHECK (product_type IN ('standalone', 'kit_virtual', 'kit_assembled')),
google_product_category TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')),
published BOOLEAN NOT NULL DEFAULT TRUE,
option1_name TEXT,
option2_name TEXT,
option3_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX product_handle_key ON product (storefront_id, handle); -- INV-13
-- variant — one purchasable form, identified by its option-value combo (INV-13).
-- SKU is indexed data, never identity. image_id FK is added after product_image.
CREATE TABLE variant (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product (id),
position INTEGER NOT NULL,
option1_value TEXT,
option2_value TEXT,
option3_value TEXT,
sku TEXT,
barcode TEXT,
price NUMERIC,
cost NUMERIC,
weight NUMERIC,
weight_unit TEXT,
volume NUMERIC,
volume_unit TEXT,
tax_id_1 TEXT,
tax_id_2 TEXT,
inventory_tracker TEXT,
inventory_qty INTEGER,
image_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Postgres 16 (compose + Cloud SQL pin): NULLS NOT DISTINCT makes the all-NULL
-- no-option combo unique too (INV-13).
CREATE UNIQUE INDEX variant_option_combo_key
ON variant (product_id, option1_value, option2_value, option3_value)
NULLS NOT DISTINCT;
CREATE INDEX variant_sku_idx ON variant (sku);
-- product_image — identity within a product is source_url (§6.3); bytes live in
-- object storage from SLICE-7 (keys nullable until fetched). status starts 'pending';
-- SLICE-5 stubs the fetch phase so rows simply stay pending.
CREATE TABLE product_image (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product (id),
position INTEGER NOT NULL,
source_url TEXT NOT NULL,
alt_text TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'fetched', 'rejected_low_res', 'rejected_not_image', 'failed')),
failure_reason TEXT,
key_original TEXT,
key_thumb TEXT,
key_card TEXT,
key_detail TEXT,
import_run_id BIGINT,
fetched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX product_image_src_key ON product_image (product_id, source_url);
ALTER TABLE variant
ADD CONSTRAINT variant_image_fk FOREIGN KEY (image_id) REFERENCES product_image (id);
-- import_draft — the preview's server side (INV-11). file_bytes is the SLICE-5
-- interim home for the upload (objectstore key from SLICE-7). Deleted outright on
-- cancel/expiry — drafts never appear in history (PUC-3a).
CREATE TABLE import_draft (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
account_id BIGINT NOT NULL REFERENCES account (id),
file_name TEXT NOT NULL,
dialect TEXT NOT NULL,
file_bytes BYTEA NOT NULL,
summary JSONB NOT NULL,
records JSONB NOT NULL,
fingerprint TEXT NOT NULL,
unknown_columns TEXT[] NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- import_run — the durable record of one confirmed import (PUC-8); created only at
-- confirm (PUC-4).
CREATE TABLE import_run (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
account_id BIGINT NOT NULL REFERENCES account (id),
file_name TEXT NOT NULL,
dialect TEXT NOT NULL,
products_added INTEGER NOT NULL,
products_updated INTEGER NOT NULL,
rows_errored INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('applying', 'fetching_images', 'complete', 'complete_with_problems')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX import_run_history_idx ON import_run (storefront_id, created_at DESC);
-- import_run_error — one row per rejected CSV row (PUC-5), merchant-language message.
CREATE TABLE import_run_error (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES import_run (id),
line_number INTEGER NOT NULL,
column_name TEXT,
message TEXT NOT NULL
);
ALTER TABLE product_image
ADD CONSTRAINT product_image_run_fk FOREIGN KEY (import_run_id) REFERENCES import_run (id);
+2
View File
@@ -5,3 +5,5 @@ psycopg[binary]>=3.1
psycopg-pool>=3.2
pytest>=8.0
import-linter>=2.0
nh3>=0.2
python-multipart>=0.0.9
@@ -0,0 +1,91 @@
import re
import psycopg
import pytest
from app.domains import accounts
from app.platform import db, mailer
@pytest.fixture()
def conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as c:
db.migrate(c)
yield c
def _code_in(outbox) -> str:
# The 6-digit code appears in the latest message body (LogMailer dev channel).
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
def test_14_01_0002_request_code_sends_a_six_digit_code(conn):
m = mailer.LogMailer()
accounts.request_code(conn, m, "Merchant@Example.com")
assert len(m.outbox) == 1
assert m.outbox[-1].to == "merchant@example.com" # normalized recipient (INV-2)
assert re.search(r"\b\d{6}\b", m.outbox[-1].body)
def test_14_01_0003_request_code_stores_only_a_hash(conn):
m = mailer.LogMailer()
accounts.request_code(conn, m, "merchant@example.com")
code = _code_in(m.outbox)
rows = conn.execute("SELECT code_hash FROM auth_code WHERE email = %s", ("merchant@example.com",)).fetchall()
assert len(rows) == 1
# The plaintext code is never stored (INV-3).
assert code not in rows[0][0]
assert len(rows[0][0]) >= 16 # a hash, not the 6-digit code
def test_request_code_uniform_for_new_and_known(conn):
# No account enumeration (§6.6): the call behaves identically whether or not the email
# already has an account — it never signals existence and never raises.
m = mailer.LogMailer()
accounts.request_code(conn, m, "newcomer@example.com") # no account exists
# create an account, then request again for a known email
conn.execute("INSERT INTO account (email) VALUES (%s)", ("known@example.com",))
conn.commit()
accounts.request_code(conn, m, "known@example.com")
assert len(m.outbox) == 2 # both sent a code; caller cannot tell new from known
def test_puc_02c_resend_is_rate_limited(conn):
m = mailer.LogMailer()
accounts.request_code(conn, m, "merchant@example.com")
with pytest.raises(accounts.ResendCooldown) as exc:
accounts.request_code(conn, m, "merchant@example.com")
assert 0 < exc.value.retry_after_s <= 60
assert len(m.outbox) == 1 # the second code was NOT sent
def test_request_code_rejects_invalid_email(conn):
m = mailer.LogMailer()
with pytest.raises(accounts.InvalidEmail):
accounts.request_code(conn, m, "not-an-email")
assert m.outbox == []
class _FailingMailer:
"""A mailer whose relay always refuses — the INV-9 honest-failure path."""
def send(self, to: str, subject: str, body: str) -> None:
raise mailer.MailerError("relay refused")
def test_delivery_failure_leaves_no_orphan_code(conn):
# ecomm#7: send happens BEFORE commit — a failed delivery must not strand an
# auth_code row (which would also trip the resend cooldown for 60s).
with pytest.raises(accounts.DeliveryFailed):
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
rows = conn.execute("SELECT count(*) FROM auth_code").fetchone()[0]
assert rows == 0
def test_delivery_failure_does_not_trip_cooldown(conn):
with pytest.raises(accounts.DeliveryFailed):
accounts.request_code(conn, _FailingMailer(), "merchant@example.com")
# the immediate retry (relay back up) succeeds — no ResendCooldown
m = mailer.LogMailer()
accounts.request_code(conn, m, "merchant@example.com")
assert len(m.outbox) == 1
+100
View File
@@ -0,0 +1,100 @@
import re
from datetime import datetime, timedelta, timezone
import psycopg
import pytest
from app.domains import accounts
from app.platform import db, mailer
@pytest.fixture()
def conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as c:
db.migrate(c)
yield c
def _issue(conn, email) -> str:
m = mailer.LogMailer()
accounts.request_code(conn, m, email)
return re.search(r"\b(\d{6})\b", m.outbox[-1].body).group(1)
def test_14_01_0004_verify_creates_account_and_reports_created(conn):
code = _issue(conn, "merchant@example.com")
account, created = accounts.verify(conn, "merchant@example.com", code)
assert created is True
assert account.email == "merchant@example.com"
assert account.id > 0
# the account row exists exactly once (INV-2)
n = conn.execute("SELECT count(*) FROM account WHERE email = %s", ("merchant@example.com",)).fetchone()[0]
assert n == 1
def test_14_01_0009_returning_account_reports_not_created(conn):
# First sign-up creates the account.
code1 = _issue(conn, "merchant@example.com")
a1, created1 = accounts.verify(conn, "merchant@example.com", code1)
assert created1 is True
# A later login resolves to the SAME account (INV-2 — email is canonical), created=False.
code2 = _issue(conn, "Merchant@Example.com") # different casing, same identity
a2, created2 = accounts.verify(conn, "Merchant@Example.com", code2)
assert created2 is False
assert a2.id == a1.id
n = conn.execute("SELECT count(*) FROM account").fetchone()[0]
assert n == 1 # no duplicate account (14.01.0010)
def test_verify_consumes_the_code_single_use(conn):
code = _issue(conn, "merchant@example.com")
accounts.verify(conn, "merchant@example.com", code)
# Reusing a consumed code is refused (single-use, INV-3): no live code -> CodeExpired.
with pytest.raises(accounts.CodeExpired):
accounts.verify(conn, "merchant@example.com", code)
def test_puc_02a_wrong_code_decrements_attempts(conn):
_issue(conn, "merchant@example.com")
with pytest.raises(accounts.CodeMismatch) as exc:
accounts.verify(conn, "merchant@example.com", "000000")
# 5 attempts allowed; one wrong -> 4 remaining (INV-3).
assert exc.value.attempts_remaining == 4
def test_puc_02b_expired_code_is_refused(conn):
code = _issue(conn, "merchant@example.com")
# Force the live code to be expired.
conn.execute(
"UPDATE auth_code SET expires_at = %s WHERE email = %s",
(datetime.now(timezone.utc) - timedelta(minutes=1), "merchant@example.com"),
)
conn.commit()
with pytest.raises(accounts.CodeExpired):
accounts.verify(conn, "merchant@example.com", code)
def test_inv3_attempts_exhausted_invalidates_code(conn):
code = _issue(conn, "merchant@example.com")
# Five wrong attempts: the 5th raises CodeExhausted and invalidates the code.
for _ in range(4):
with pytest.raises(accounts.CodeMismatch):
accounts.verify(conn, "merchant@example.com", "000000")
with pytest.raises(accounts.CodeExhausted):
accounts.verify(conn, "merchant@example.com", "000000")
# Even the correct code no longer works — a fresh request is required.
with pytest.raises((accounts.CodeExpired, accounts.CodeExhausted)):
accounts.verify(conn, "merchant@example.com", code)
def test_verify_with_no_request_is_code_expired(conn):
with pytest.raises(accounts.CodeExpired):
accounts.verify(conn, "stranger@example.com", "123456")
def test_get_account_loads_by_id(conn):
code = _issue(conn, "merchant@example.com")
account, _ = accounts.verify(conn, "merchant@example.com", code)
loaded = accounts.get_account(conn, account.id)
assert loaded is not None and loaded.email == "merchant@example.com"
assert accounts.get_account(conn, 999999) is None
+146
View File
@@ -0,0 +1,146 @@
import re
from fastapi.testclient import TestClient
from app.main import create_app
def _client(fresh_db_url) -> TestClient:
# A LogMailer on app.state lets the test read back the issued code (§6.8).
return TestClient(create_app(database_url=fresh_db_url))
def _last_code(client) -> str:
outbox = client.app.state.mailer.outbox
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
def test_request_code_returns_204(fresh_db_url):
with _client(fresh_db_url) as client:
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
assert resp.status_code == 204
def test_request_code_invalid_email_is_400(fresh_db_url):
with _client(fresh_db_url) as client:
resp = client.post("/api/auth/request-code", json={"email": "nope"})
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "invalid_email"
def test_request_code_uniform_204_no_enumeration(fresh_db_url):
# New and known emails both return 204 with no distinguishing body (§6.6).
with _client(fresh_db_url) as client:
r_new = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
# sign the newcomer up so the email becomes "known"
code = _last_code(client)
client.post("/api/auth/verify", json={"email": "newcomer@example.com", "code": code})
r_known = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
assert r_new.status_code == r_known.status_code == 204
assert r_new.content == r_known.content == b""
def test_resend_cooldown_is_429_with_retry_after(fresh_db_url):
with _client(fresh_db_url) as client:
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
assert resp.status_code == 429
body = resp.json()["error"]
assert body["code"] == "resend_cooldown"
assert body["retry_after_s"] > 0
def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
with _client(fresh_db_url) as client:
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
code = _last_code(client)
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 "ecomm_session" in resp.cookies
def test_verify_returning_account_created_false(fresh_db_url):
# BUC-2 over HTTP: a returning merchant resumes the same account, created=False. The
# second request-code would hit the 60s cooldown, so backdate the consumed code's
# created_at to satisfy it (rather than sleeping 60s in a test).
import psycopg
from datetime import datetime, timedelta, timezone
with _client(fresh_db_url) as client:
client.post("/api/auth/request-code", json={"email": "m@example.com"})
r1 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
assert r1.json()["created"] is True
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), "m@example.com"),
)
c.commit()
client.post("/api/auth/request-code", json={"email": "m@example.com"})
r2 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
assert r2.status_code == 200
assert r2.json()["created"] is False
assert r2.json()["account"] == {"email": "m@example.com"}
def test_verify_wrong_code_is_400_code_mismatch(fresh_db_url):
with _client(fresh_db_url) as client:
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": "000000"})
assert resp.status_code == 400
body = resp.json()["error"]
assert body["code"] == "code_mismatch"
assert body["attempts_remaining"] == 4
def test_verify_no_code_is_400_code_expired(fresh_db_url):
with _client(fresh_db_url) as client:
resp = client.post("/api/auth/verify", json={"email": "stranger@example.com", "code": "123456"})
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "code_expired"
def test_me_requires_session(fresh_db_url):
with _client(fresh_db_url) as client:
resp = client.get("/api/auth/me")
assert resp.status_code == 401
assert resp.json()["error"]["code"] == "unauthenticated"
def test_me_returns_account_with_session(fresh_db_url):
with _client(fresh_db_url) as client:
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
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}
def test_puc_09_logout_clears_session(fresh_db_url):
with _client(fresh_db_url) as client:
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
assert client.get("/api/auth/me").status_code == 200
logout = client.post("/api/auth/logout")
assert logout.status_code == 204
# after logout the cookie is cleared -> /me is unauthenticated again
client.cookies.clear()
assert client.get("/api/auth/me").status_code == 401
def test_request_code_delivery_failure_is_502(fresh_db_url, monkeypatch):
# INV-9 honest failure over HTTP (§6.4): the relay refused -> 502 delivery_failed,
# never a fake 204. Force the app's mailer to fail after startup.
from app.platform import mailer as mailer_mod
class _FailingMailer:
def send(self, to, subject, body):
raise mailer_mod.MailerError("relay refused")
with _client(fresh_db_url) as client:
client.app.state.mailer = _FailingMailer()
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
assert resp.status_code == 502
assert resp.json()["error"]["code"] == "delivery_failed"
+42
View File
@@ -0,0 +1,42 @@
"""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."""
import re
from fastapi.testclient import TestClient
from app.main import create_app
def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url):
# fresh_db_url is a brand-new empty database; create_app() self-migrates (INV-7).
with TestClient(create_app(database_url=fresh_db_url)) as client:
# a fresh deployment serves healthz green before any row exists
assert client.get("/healthz").json()["status"] == "ok"
# PUC-2: first visitor requests a code; it reaches them via the dev channel
assert client.post(
"/api/auth/request-code", json={"email": "first@example.com"}
).status_code == 204
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
# verify creates account #1 — the first row, through the product alone
verified = client.post(
"/api/auth/verify", json={"email": "first@example.com", "code": code}
)
assert verified.status_code == 200
assert verified.json()["created"] is True
assert verified.json()["storefront"] is None # -> create-storefront (PUC-5)
# 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
me = client.get("/api/auth/me")
assert me.status_code == 200
assert me.json() == {
"account": {"email": "first@example.com"},
"storefront": created.json(),
}
+30
View File
@@ -11,3 +11,33 @@ def test_database_url_defaults_to_local_compose(monkeypatch):
def test_database_url_honors_env(monkeypatch):
monkeypatch.setenv("ECOMM_DATABASE_URL", "postgresql://x:y@db:5432/z")
assert config.database_url() == "postgresql://x:y@db:5432/z"
def test_session_secret_defaults_for_dev(monkeypatch):
monkeypatch.delenv("ECOMM_SESSION_SECRET", raising=False)
# A non-empty dev default so localhost works with no setup; deployed overrides it.
assert config.session_secret()
def test_session_secret_honors_env(monkeypatch):
monkeypatch.setenv("ECOMM_SESSION_SECRET", "s3cret")
assert config.session_secret() == "s3cret"
def test_cookie_secure_defaults_false(monkeypatch):
monkeypatch.delenv("ECOMM_COOKIE_SECURE", raising=False)
assert config.cookie_secure() is False
def test_cookie_secure_truthy_env(monkeypatch):
monkeypatch.setenv("ECOMM_COOKIE_SECURE", "1")
assert config.cookie_secure() is True
monkeypatch.setenv("ECOMM_COOKIE_SECURE", "true")
assert config.cookie_secure() is True
monkeypatch.setenv("ECOMM_COOKIE_SECURE", "0")
assert config.cookie_secure() is False
def test_mailer_kind_defaults_to_log(monkeypatch):
monkeypatch.delenv("ECOMM_MAILER", raising=False)
assert config.mailer_kind() == "log"
+7 -1
View File
@@ -1,14 +1,20 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import create_app
# /healthz reports the VERSION file's value — the flotilla deploy gate compares
# body.version against the pinned target (flotilla-core SPEC §8.1 phase 8).
_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()
def test_healthz_ok_on_migrated_empty_db(fresh_db_url):
app = create_app(database_url=fresh_db_url)
with TestClient(app) as client:
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
assert resp.json() == {"status": "ok", "version": _VERSION}
def test_startup_migrates_from_empty(fresh_db_url):
+111
View File
@@ -0,0 +1,111 @@
from app.platform import mailer
def test_logmailer_captures_to_outbox():
m = mailer.LogMailer()
assert m.outbox == []
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456 (valid 10 min)")
assert len(m.outbox) == 1
msg = m.outbox[-1]
assert msg.to == "merchant@example.com"
assert msg.subject == "Your ecomm code: 123456"
assert "123456" in msg.body
def test_build_mailer_log_kind():
m = mailer.build_mailer("log")
assert isinstance(m, mailer.LogMailer)
class _FakeSMTP:
"""Stand-in for smtplib.SMTP capturing the call sequence (no network)."""
instances: list["_FakeSMTP"] = []
fail_on_send = False
def __init__(self, host, port, timeout=None):
self.host, self.port, self.timeout = host, port, timeout
self.calls: list[str] = []
self.message = None
_FakeSMTP.instances.append(self)
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def starttls(self):
self.calls.append("starttls")
def login(self, user, password):
self.calls.append(f"login:{user}")
def send_message(self, msg):
if _FakeSMTP.fail_on_send:
raise RuntimeError("relay refused")
self.calls.append("send_message")
self.message = msg
def _smtp_env(monkeypatch):
monkeypatch.setattr(mailer.smtplib, "SMTP", _FakeSMTP)
_FakeSMTP.instances.clear()
_FakeSMTP.fail_on_send = False
monkeypatch.setenv("ECOMM_SMTP_HOST", "smtp.example.com")
monkeypatch.setenv("ECOMM_SMTP_PORT", "587")
monkeypatch.setenv("ECOMM_SMTP_USER", "sender@example.com")
monkeypatch.setenv("ECOMM_SMTP_PASSWORD", "not-a-real-password")
monkeypatch.setenv("ECOMM_SMTP_FROM", "ecomm <sender@example.com>")
def test_build_mailer_smtp_builds_from_config(monkeypatch):
_smtp_env(monkeypatch)
m = mailer.build_mailer("smtp")
assert isinstance(m, mailer.SmtpMailer)
def test_smtpmailer_sends_via_starttls_login(monkeypatch):
_smtp_env(monkeypatch)
m = mailer.build_mailer("smtp")
m.send("merchant@example.com", "Your ecomm code: 123456", "Code: 123456")
smtp = _FakeSMTP.instances[-1]
assert (smtp.host, smtp.port) == ("smtp.example.com", 587)
assert smtp.calls == ["starttls", "login:sender@example.com", "send_message"]
assert smtp.message["To"] == "merchant@example.com"
assert smtp.message["Subject"] == "Your ecomm code: 123456"
assert smtp.message["From"] == "ecomm <sender@example.com>"
assert "123456" in smtp.message.get_content()
def test_smtpmailer_failure_raises_mailer_error(monkeypatch):
_smtp_env(monkeypatch)
_FakeSMTP.fail_on_send = True
import pytest
m = mailer.build_mailer("smtp")
with pytest.raises(mailer.MailerError):
m.send("merchant@example.com", "subject", "body")
def test_smtpmailer_never_logs_the_body(monkeypatch):
# §6.6 log hygiene: codes never appear in deployed logs. LogMailer logging the body
# is dev-only by configuration; the deployed adapter must not. The handler attaches
# directly to the ecomm.mailer logger (it doesn't propagate to root).
import logging
_smtp_env(monkeypatch)
records: list[str] = []
handler = logging.Handler()
handler.emit = lambda r: records.append(r.getMessage()) # type: ignore[method-assign]
lg = logging.getLogger("ecomm.mailer")
lg.addHandler(handler)
lg.setLevel(logging.DEBUG)
try:
mailer.build_mailer("smtp").send(
"merchant@example.com", "Your ecomm code: 123456", "Code: 123456"
)
finally:
lg.removeHandler(handler)
assert records, "the deployed adapter should log the send event (observability §6.6)"
assert all("123456" not in r and "merchant@example.com" not in r for r in records)
+24 -2
View File
@@ -1,4 +1,5 @@
import psycopg
import pytest
from app.platform import db
@@ -12,10 +13,10 @@ def _table_names(conn) -> set[str]:
return {r[0] for r in rows}
def test_migrate_from_empty_applies_0001(fresh_db_url):
def test_migrate_from_empty_applies_all(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
applied = db.migrate(conn)
assert applied == ["0001_init.sql"]
assert applied == ["0001_init.sql", "0002_products.sql"]
with psycopg.connect(fresh_db_url) as conn:
assert _TABLES.issubset(_table_names(conn))
@@ -41,3 +42,24 @@ def test_membership_has_no_unique_account_constraint(fresh_db_url):
).fetchall()
defs = " ".join(r[0] for r in rows).lower()
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
def test_0002_products_tables_exist(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
for table in ("product", "variant", "product_image", "import_draft", "import_run", "import_run_error"):
assert conn.execute("SELECT to_regclass(%s)", (f"public.{table}",)).fetchone()[0] == table
def test_0002_variant_option_combo_unique_treats_nulls_as_equal(fresh_db_url):
# INV-13: the no-option product's single variant has NULL option values; a second
# all-NULL combo must collide (NULLS NOT DISTINCT).
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
sf = conn.execute("INSERT INTO storefront (name) VALUES ('s') RETURNING id").fetchone()[0]
pid = conn.execute(
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'h','T') RETURNING id", (sf,)
).fetchone()[0]
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 1)", (pid,))
with pytest.raises(psycopg.errors.UniqueViolation):
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 2)", (pid,))
+69
View File
@@ -0,0 +1,69 @@
"""§6.5.1 file-level codec — parse, caps, required columns (PUC-5a fixtures)."""
import pytest
from app.domains.products import FileRejected
from app.domains.products.codec import parse_csv
def _csv(*lines: str) -> bytes:
return ("\n".join(lines) + "\n").encode()
GOOD = _csv(
"Handle,Title,Variant Price,Bogus Column",
"moon-mug,Moon Mug,18.00,x",
"star-tee,Star Tee,24.00,y",
)
def test_parses_header_rows_and_unknown_columns():
parsed = parse_csv(GOOD)
assert parsed.dialect == "canonical"
assert parsed.unknown_columns == ["Bogus Column"]
assert [r.line_number for r in parsed.rows] == [2, 3]
assert parsed.rows[0].cells["Handle"] == "moon-mug"
assert parsed.rows[0].cells["Variant Price"] == "18.00"
assert "Bogus Column" not in parsed.rows[0].cells
def test_bom_tolerated():
parsed = parse_csv(b"\xef\xbb\xbf" + GOOD)
assert parsed.rows[0].cells["Handle"] == "moon-mug"
def test_quoted_cells_rfc4180():
parsed = parse_csv(_csv("Handle,Title,Tags", 'mug,"The ""Best"" Mug","a, b"'))
assert parsed.rows[0].cells["Title"] == 'The "Best" Mug'
assert parsed.rows[0].cells["Tags"] == "a, b"
def test_empty_rows_skipped_short_rows_padded():
parsed = parse_csv(_csv("Handle,Title,Vendor", "mug,Mug", "", ",,", "tee,Tee,Acme"))
assert [r.cells["Handle"] for r in parsed.rows] == ["mug", "tee"]
assert parsed.rows[0].cells["Vendor"] == ""
@pytest.mark.parametrize(
"data,code",
[
(b"\xff\xfe\x00garbage\x00", "not_csv"),
(b"", "not_csv"),
(_csv("Title,Vendor", "Mug,Acme"), "missing_required_column"),
(_csv("Handle,Vendor", "mug,Acme"), "missing_required_column"),
(
_csv("Handle,Title", *(f"h{i},T{i}" for i in range(5001))),
"too_many_rows",
),
(b"Handle,Title\n" + b"x" * (10 * 1024 * 1024), "file_too_large"),
],
)
def test_file_level_rejections(data, code):
with pytest.raises(FileRejected) as exc:
parse_csv(data)
assert exc.value.code == code
def test_missing_column_message_names_the_column():
with pytest.raises(FileRejected) as exc:
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
assert "'Title'" in exc.value.message
+146
View File
@@ -0,0 +1,146 @@
"""Diff engine — classification, blank-vs-absent, option matching, fingerprint (§6.8)."""
from decimal import Decimal
from app.domains.products.codec import parse_csv
from app.domains.products.diff import (
CatalogImage, CatalogProduct, CatalogVariant, compute_diff,
)
from app.domains.products.validate import build_products
def _canon(*lines: str):
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
def _catalog_mug(**overrides):
fields = {
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": ["kitchen"], "status": "active", "published": True,
} | overrides
return {
"moon-mug": CatalogProduct(
id=1, handle="moon-mug", title=fields["title"],
option_names=(None, None, None), fields=fields,
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
fields={"sku": "SKU-1", "barcode": None, "price": Decimal("18.00"),
"cost": None, "weight": None, "weight_unit": None,
"volume": None, "volume_unit": None, "tax_id_1": None,
"tax_id_2": None, "inventory_tracker": None,
"inventory_qty": 40, "variant_image": None})],
images=[CatalogImage(id=100, source_url="https://x/a.jpg", position=1, alt_text=None)],
)
}
HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src"
MUG_ROW = 'moon-mug,Moon Mug,Acme,kitchen,active,SKU-1,18.00,40,https://x/a.jpg'
def test_new_handle_classifies_add():
diff = compute_diff({}, _canon(HEADER, MUG_ROW))
[rec] = diff.records
assert rec["kind"] == "add" and rec["handle"] == "moon-mug" and rec["variant_count"] == 1
assert diff.summary == {"adds": 1, "updates": 0, "unchanged": 0, "errors": 0}
assert rec["detail"]["set"]["status"] == "active"
def test_identical_file_classifies_unchanged():
diff = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
assert diff.records[0]["kind"] == "unchanged"
assert diff.summary["unchanged"] == 1
def test_changed_price_classifies_update_with_before_after():
row = MUG_ROW.replace("18.00", "21.00")
diff = compute_diff(_catalog_mug(), _canon(HEADER, row))
[rec] = diff.records
assert rec["kind"] == "update"
[vchange] = rec["detail"]["variants"]
assert {"field": "price", "before": "18.00", "after": "21.00"} in vchange["changes"]
def test_absent_column_untouched_empty_cell_clears():
# Vendor column absent: vendor stays Acme. Status present-but-empty: clears to default 'active' (already active -> no change).
diff = compute_diff(
_catalog_mug(),
_canon("Handle,Title,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
)
assert diff.records[0]["kind"] == "unchanged"
def test_empty_cell_clear_shows_in_diff():
# Vendor present-but-empty clears Acme -> None: an explicit, previewable change (§6.5.1).
diff = compute_diff(
_catalog_mug(),
_canon("Handle,Title,Vendor,Variant SKU,Variant Price,Variant Inventory Qty,Image Src",
"moon-mug,Moon Mug,,SKU-1,18.00,40,https://x/a.jpg"),
)
[rec] = diff.records
assert rec["kind"] == "update"
assert {"field": "vendor", "before": "Acme", "after": None} in rec["detail"]["changes"]
def test_new_option_combo_is_variant_add_existing_untouched():
catalog = _catalog_mug()
diff = compute_diff(
catalog,
_canon("Handle,Title,Option1 Name,Option1 Value,Variant Price",
"moon-mug,Moon Mug,Size,Large,25.00"),
)
[rec] = diff.records
assert rec["kind"] == "update"
kinds = [v.get("kind") for v in rec["detail"]["variants"]]
assert "add" in kinds
POSITION_HEADER = HEADER + ",Variant Position"
def test_matching_variant_position_column_classifies_unchanged():
# position is an attribute on CatalogVariant (not in fields{}); the compare
# must read it from there, not invent a before:None.
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",1"))
assert diff.records[0]["kind"] == "unchanged"
def test_changed_variant_position_reports_honest_before():
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ",2"))
[rec] = diff.records
assert rec["kind"] == "update"
[ventry] = rec["detail"]["variants"]
assert {"field": "position", "before": 1, "after": 2} in ventry["changes"]
def test_blank_position_cell_resolves_to_file_order_unchanged():
# A present-but-empty Variant Position cell resets to file order (the spec's
# "defaults to file order"), never to NULL — here file order matches the
# catalog position, so nothing changes.
diff = compute_diff(_catalog_mug(), _canon(POSITION_HEADER, MUG_ROW + ","))
assert diff.records[0]["kind"] == "unchanged"
def test_blank_position_cell_updates_to_file_order():
catalog = _catalog_mug()
catalog["moon-mug"].variants[0].position = 2
diff = compute_diff(catalog, _canon(POSITION_HEADER, MUG_ROW + ","))
[rec] = diff.records
assert rec["kind"] == "update"
[ventry] = rec["detail"]["variants"]
assert {"field": "position", "before": 2, "after": 1} in ventry["changes"]
def test_error_product_classifies_error():
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
[rec] = diff.records
assert rec["kind"] == "error"
assert rec["detail"]["errors"][0]["column"] == "Variant Price"
def test_fingerprint_stable_and_drift_sensitive():
d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW))
assert d1.fingerprint == d2.fingerprint
assert d1.fingerprint != d3.fingerprint
+135
View File
@@ -0,0 +1,135 @@
"""§6.4 /api/products/* endpoint scenarios (PUC-2/3/3a/4/5/5a/8 + gates)."""
import io
import re
from contextlib import contextmanager
from fastapi.testclient import TestClient
from app.main import create_app
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\n"
@contextmanager
def _merchant_client(fresh_db_url, email="m@example.com"):
with TestClient(create_app(database_url=fresh_db_url)) as client:
client.post("/api/auth/request-code", json={"email": email})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
client.post("/api/auth/verify", json={"email": email, "code": code})
client.post("/api/storefronts", json={})
yield client
def _upload(client, data=GOOD_CSV, name="cat.csv"):
return client.post("/api/products/imports", files={"file": (name, io.BytesIO(data), "text/csv")})
def test_upload_returns_201_draft(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
resp = _upload(client)
assert resp.status_code == 201
body = resp.json()
assert body["summary"]["adds"] == 1 and body["dialect"] == "canonical"
def test_upload_rejections_carry_codes(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
resp = _upload(client, b"Vendor\nAcme\n")
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "missing_required_column"
resp = _upload(client, b"Handle,Title\n" + b"x" * (10 * 1024 * 1024 + 1))
assert resp.status_code == 413
def test_unauthenticated_401_and_no_storefront_404(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
assert _upload(client).status_code == 401
client.post("/api/auth/request-code", json={"email": "x@example.com"})
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
client.post("/api/auth/verify", json={"email": "x@example.com", "code": code})
assert _upload(client).status_code == 404
def test_preview_confirm_run_flow(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
draft = _upload(client).json()
recs = client.get(f"/api/products/imports/drafts/{draft['id']}/records").json()["records"]
assert recs[0]["kind"] == "add"
run_id = client.post(f"/api/products/imports/drafts/{draft['id']}/confirm").json()["run_id"]
run = client.get(f"/api/products/imports/runs/{run_id}").json()
assert run["products_added"] == 1 and run["by"] == "m@example.com"
assert client.get("/api/products/summary").json()["product_count"] == 1
assert client.get("/api/products/imports/runs").json()["runs"][0]["id"] == run_id
def test_cancel_no_trace_puc3a(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
draft = _upload(client).json()
assert client.delete(f"/api/products/imports/drafts/{draft['id']}").status_code == 204
assert client.get(f"/api/products/imports/drafts/{draft['id']}").status_code == 404
assert client.get("/api/products/imports/runs").json()["runs"] == []
def test_confirm_conflicts(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
d1 = _upload(client).json()
client.post(f"/api/products/imports/drafts/{d1['id']}/confirm")
d2 = _upload(client).json()
resp = client.post(f"/api/products/imports/drafts/{d2['id']}/confirm")
assert resp.status_code == 409 and resp.json()["error"]["code"] == "nothing_to_apply"
def test_sample_csv_served(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
resp = client.get("/api/products/sample.csv")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/csv")
assert resp.text.startswith("Handle,Title,")
def test_sample_csv_imports_clean(fresh_db_url):
"""DOC-3 honesty: our own sample must validate with zero errors."""
with _merchant_client(fresh_db_url) as client:
sample = client.get("/api/products/sample.csv").content
body = _upload(client, sample, "sample.csv").json()
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2
def test_export_returns_canonical_csv(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
draft = _upload(client).json()
client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
resp = client.get("/api/products/export")
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/csv")
assert "attachment" in resp.headers["content-disposition"]
body = resp.text
assert body.splitlines()[0].startswith("Handle,")
assert "moon-mug" in body
def test_export_status_filter_respected(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
# GOOD_CSV's moon-mug has no Status column → defaults to active.
draft = _upload(client).json()
client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
assert "moon-mug" in client.get("/api/products/export?status=active").text
# No archived products → 409.
assert client.get("/api/products/export?status=archived").status_code == 409
def test_export_empty_catalog_409(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
resp = client.get("/api/products/export")
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "empty_catalog"
def test_export_requires_merchant(fresh_db_url):
with TestClient(create_app(database_url=fresh_db_url)) as client:
assert client.get("/api/products/export").status_code == 401
def test_export_bad_status_422(fresh_db_url):
with _merchant_client(fresh_db_url) as client:
assert client.get("/api/products/export?status=bogus").status_code == 422
+100
View File
@@ -0,0 +1,100 @@
"""Export: status-filtered catalog snapshot + the streamed service (PUC-9, TEL-3)."""
import csv
import io
import json
import logging
import psycopg
import pytest
from app.domains import products
from app.domains.products import repo
from app.platform import db
CSV = (
b"Handle,Title,Vendor,Status,Variant Price\n"
b"active-mug,Active Mug,Acme,active,18.00\n"
b"draft-tee,Draft Tee,Acme,draft,24.00\n"
)
@pytest.fixture()
def migrated_conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
yield conn
@pytest.fixture()
def merchant(migrated_conn):
acct = migrated_conn.execute(
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
sf = migrated_conn.execute(
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
migrated_conn.execute(
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
migrated_conn.commit()
return {"account_id": acct, "storefront_id": sf}
def _seed(conn, merchant):
draft = products.import_validate(conn, merchant["storefront_id"], merchant["account_id"], "c.csv", CSV)
products.confirm_draft(conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
def test_export_all_returns_both(migrated_conn, merchant):
_seed(migrated_conn, merchant)
snap = repo.export_catalog(migrated_conn, merchant["storefront_id"], "all")
assert {p.handle for p in snap} == {"active-mug", "draft-tee"}
def test_export_status_filter(migrated_conn, merchant):
_seed(migrated_conn, merchant)
active = repo.export_catalog(migrated_conn, merchant["storefront_id"], "active")
assert [p.handle for p in active] == ["active-mug"]
draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft")
assert [p.handle for p in draft] == ["draft-tee"]
@pytest.fixture()
def telemetry_propagation():
"""create_app() sets propagate=False on the parent "ecomm" logger
(main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
root-logger handler whenever an API test ran first. Restore propagation here."""
lg = logging.getLogger("ecomm")
prior = lg.propagate
lg.propagate = True
yield
lg.propagate = prior
def test_export_streams_canonical_csv(migrated_conn, merchant):
_seed(migrated_conn, merchant)
text = "".join(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
rows = list(csv.DictReader(io.StringIO(text)))
assert {r["Handle"] for r in rows} == {"active-mug", "draft-tee"}
assert rows[0]["Handle"] == "active-mug" # sorted by handle
def test_export_empty_raises(migrated_conn, merchant):
# No catalog at all → empty.
with pytest.raises(products.EmptyCatalog):
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
def test_export_empty_after_filter_raises(migrated_conn, merchant):
_seed(migrated_conn, merchant) # only active + draft exist
with pytest.raises(products.EmptyCatalog):
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "archived"))
def test_tel3_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
_seed(migrated_conn, merchant)
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
exported = [e for e in events if e["event"] == "catalog_exported"]
assert len(exported) == 1
assert exported[0]["product_count"] == 2
assert exported[0]["status_filter"] == "all"
assert "duration_ms" in exported[0]
+79
View File
@@ -0,0 +1,79 @@
"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed),
apply transactionality (§6.8), TEL-6."""
import json
import logging
import psycopg
import pytest
from app.domains import products
from app.domains.products import repo, service
from app.platform import db
CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n"
CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n"
@pytest.fixture()
def migrated_conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
yield conn
def _merchant(conn, email="m@example.com", shop="Shop"):
acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0]
sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0]
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
conn.commit()
return acct, sf
def _import(conn, acct, sf, data):
d = products.import_validate(conn, sf, acct, "f.csv", data)
return products.confirm_draft(conn, sf, acct, d["id"])
def test_inv10_partial_file_never_deletes(migrated_conn):
acct, sf = _merchant(migrated_conn)
_import(migrated_conn, acct, sf, CSV_A)
before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
_import(migrated_conn, acct, sf, CSV_PARTIAL)
after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
assert after >= before == 2
def test_inv14_two_storefronts_zero_bleed(migrated_conn):
acct1, sf1 = _merchant(migrated_conn)
acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other")
_import(migrated_conn, acct1, sf1, CSV_A)
assert products.summary(migrated_conn, sf2)["product_count"] == 0
assert products.list_runs(migrated_conn, sf2) == []
_import(migrated_conn, acct2, sf2, CSV_A)
assert products.summary(migrated_conn, sf2)["product_count"] == 2
run1 = products.list_runs(migrated_conn, sf1)[0]
with pytest.raises(products.RunNotFound):
products.get_run(migrated_conn, sf2, run1["id"])
def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog):
acct, sf = _merchant(migrated_conn)
d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A)
def boom(*a, **k):
raise RuntimeError("mid-apply crash")
monkeypatch.setattr(service.repo, "insert_run_errors", boom)
lg = logging.getLogger("ecomm")
prior = lg.propagate
lg.propagate = True
try:
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
with pytest.raises(RuntimeError):
products.confirm_draft(migrated_conn, sf, acct, d["id"])
finally:
lg.propagate = prior
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events)
+224
View File
@@ -0,0 +1,224 @@
"""Canonical serializer + INV-12 round-trip lock (SD-0002 §6.5.5, §6.8)."""
import csv
import io
from app.domains.products import serialize
from app.domains.products.diff import CatalogImage, CatalogProduct, CatalogVariant
def _product(**kw) -> CatalogProduct:
"""A minimal no-option catalog product (one all-NULL variant)."""
base = dict(
id=1, handle="moon-mug", title="Moon Mug",
option_names=(None, None, None),
fields={
"title": "Moon Mug", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": [], "status": "active", "published": True,
},
variants=[CatalogVariant(
id=1, options=(None, None, None), position=1,
fields={"sku": "WG-MUG", "barcode": None, "price": None, "cost": None,
"weight": None, "weight_unit": None, "volume": None,
"volume_unit": None, "tax_id_1": None, "tax_id_2": None,
"inventory_tracker": None, "inventory_qty": None,
"variant_image": None})],
images=[],
)
base.update(kw)
return CatalogProduct(**base)
def _rows(products) -> list[dict]:
text = "".join(serialize.catalog_to_csv(products))
return list(csv.DictReader(io.StringIO(text)))
def test_header_is_full_canonical_set():
text = "".join(serialize.catalog_to_csv([_product()]))
header = next(csv.reader(io.StringIO(text)))
# Handle + Title first; every known column present exactly once.
assert header[0] == "Handle"
assert "Title" in header and "Variant SKU" in header and "Image Src" in header
assert len(header) == len(set(header))
def test_single_no_option_product_one_row():
rows = _rows([_product()])
assert len(rows) == 1
assert rows[0]["Handle"] == "moon-mug"
assert rows[0]["Title"] == "Moon Mug"
assert rows[0]["Variant SKU"] == "WG-MUG"
# A no-option product emits no option values.
assert rows[0]["Option1 Value"] == ""
def _star_tee() -> CatalogProduct:
"""A 3-variant, 2-image product (the sample.csv shape)."""
return CatalogProduct(
id=2, handle="star-tee", title="Star Tee",
option_names=("Size", "Color", None),
fields={"title": "Star Tee", "description_html": None, "vendor": "Acme",
"product_type": "standalone", "google_product_category": None,
"tags": ["apparel", "tees"], "status": "active", "published": True},
variants=[
CatalogVariant(id=10, options=("S", "Indigo", None), position=1,
fields={"sku": "WG-TEE-S", "variant_image": None}),
CatalogVariant(id=11, options=("M", "Indigo", None), position=2,
fields={"sku": "WG-TEE-M", "variant_image": None}),
CatalogVariant(id=12, options=("L", "Indigo", None), position=3,
fields={"sku": "WG-TEE-L", "variant_image": None}),
],
images=[
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text="front"),
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text="back"),
],
)
def test_multivariant_with_images_interleaves():
rows = _rows([_star_tee()])
assert len(rows) == 3 # max(3 variants, 2 images)
# Product-level fields only on the first row.
assert rows[0]["Title"] == "Star Tee" and rows[1]["Title"] == ""
assert rows[0]["Tags"] == "apparel, tees" and rows[1]["Tags"] == ""
# Option names on row 0; option values on every variant row.
assert rows[0]["Option1 Name"] == "Size" and rows[1]["Option1 Name"] == ""
assert [r["Option1 Value"] for r in rows] == ["S", "M", "L"]
assert [r["Variant SKU"] for r in rows] == ["WG-TEE-S", "WG-TEE-M", "WG-TEE-L"]
# Two images on the first two rows; third row has no image.
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", ""]
assert [r["Image Position"] for r in rows] == ["1", "2", ""]
def test_more_images_than_variants_emits_image_only_rows():
p = _product(images=[
CatalogImage(id=1, source_url="https://x/a.jpg", position=1, alt_text=None),
CatalogImage(id=2, source_url="https://x/b.jpg", position=2, alt_text=None),
CatalogImage(id=3, source_url="https://x/c.jpg", position=3, alt_text=None),
])
rows = _rows([p])
assert len(rows) == 3 # 1 variant, 3 images
assert rows[0]["Variant SKU"] == "WG-MUG"
assert rows[1]["Variant SKU"] == "" and rows[1]["Handle"] == "moon-mug"
assert [r["Image Src"] for r in rows] == ["https://x/a.jpg", "https://x/b.jpg", "https://x/c.jpg"]
import random
from app.domains.products import codec, diff, validate
def _gen_catalog(seed: int) -> dict:
"""A deterministic random text-field catalog, in load_catalog()'s shape."""
rng = random.Random(seed)
words = ["moon", "star", "river", "cloud", "ember", "fern", "slate", "wave"]
catalog: dict[str, CatalogProduct] = {}
n_products = rng.randint(1, 6)
for pid in range(1, n_products + 1):
handle = "-".join(rng.sample(words, rng.randint(1, 3))) + f"-{pid}"
if handle in catalog:
continue
has_opts = rng.random() < 0.6
option_names = ("Size", "Color", None) if has_opts else (None, None, None)
tags = rng.sample(["apparel", "kitchen", "sale", "new"], rng.randint(0, 3))
fields = {
"title": f"{handle.title()} Thing",
"description_html": rng.choice([None, "<p>hi</p>"]),
"vendor": rng.choice([None, "Acme", "Wiggle Goods"]),
"product_type": "standalone",
"google_product_category": rng.choice([None, "Home & Garden"]),
"tags": tags,
"status": rng.choice(["draft", "active", "archived"]),
"published": rng.choice([True, False]),
}
variants = []
if has_opts:
sizes = rng.sample(["S", "M", "L", "XL"], rng.randint(1, 4))
for vi, size in enumerate(sizes, start=1):
# Non-sequential positions (×10) so a serializer that drops the
# stored position and lets re-import default to file order is
# caught — a merchant can import explicit positions like 10/20.
variants.append(CatalogVariant(
id=pid * 100 + vi, options=(size, "Indigo", None), position=vi * 10,
fields={"sku": f"SKU-{pid}-{vi}", "variant_image": None}))
else:
variants.append(CatalogVariant(
id=pid * 100 + 1, options=(None, None, None), position=rng.choice([1, 5, 9]),
fields={"sku": f"SKU-{pid}", "variant_image": None}))
images = []
for ii in range(rng.randint(0, 3)):
images.append(CatalogImage(
id=pid * 10 + ii, source_url=f"https://img/{handle}-{ii}.jpg",
position=ii + 1, alt_text=rng.choice([None, f"alt {ii}"])))
catalog[handle] = CatalogProduct(
id=pid, handle=handle, title=fields["title"], option_names=option_names,
fields=fields, variants=variants, images=images)
return catalog
def _roundtrip_diff(catalog: dict) -> diff.DiffResult:
"""export → bytes → import pipeline → diff against the same catalog."""
text = "".join(serialize.catalog_to_csv(catalog.values()))
parsed = codec.parse_csv(text.encode("utf-8"))
products = validate.build_products(parsed)
return diff.compute_diff(catalog, products)
def test_inv12_roundtrip_is_noop_over_generated_catalogs():
for seed in range(200):
catalog = _gen_catalog(seed)
result = _roundtrip_diff(catalog)
assert result.summary["adds"] == 0, f"seed {seed}: {result.summary}"
assert result.summary["updates"] == 0, f"seed {seed}: {result.summary}"
assert result.summary["errors"] == 0, f"seed {seed}: {result.summary}"
assert result.summary["unchanged"] == len(catalog), f"seed {seed}"
from decimal import Decimal
def test_decimal_and_int_variant_fields_roundtrip():
catalog = {
"priced": CatalogProduct(
id=1, handle="priced", title="Priced",
option_names=(None, None, None),
fields={"title": "Priced", "description_html": None, "vendor": None,
"product_type": "standalone", "google_product_category": None,
"tags": [], "status": "active", "published": True},
variants=[CatalogVariant(
id=1, options=(None, None, None), position=1,
fields={"sku": "P1", "price": Decimal("18.00"),
"cost": Decimal("9.5"), "weight": Decimal("0.250"),
"inventory_qty": 40, "variant_image": None})],
images=[],
)
}
result = _roundtrip_diff(catalog)
assert result.summary["unchanged"] == 1, result.records
assert result.summary["updates"] == 0
def test_nonsequential_variant_position_roundtrips():
"""A stored variant position that isn't its file order must survive export —
else re-import reads the empty cell as 'reset to file order' and the round-trip
spuriously updates (INV-12 regression: position is a CatalogVariant attribute,
not a fields{} entry, so the serializer must emit it explicitly)."""
catalog = {
"tee": CatalogProduct(
id=1, handle="tee", title="Tee", option_names=("Size", None, None),
fields={"title": "Tee", "description_html": None, "vendor": None,
"product_type": "standalone", "google_product_category": None,
"tags": [], "status": "active", "published": True},
variants=[
CatalogVariant(id=1, options=("S", None, None), position=10,
fields={"sku": "T-S", "variant_image": None}),
CatalogVariant(id=2, options=("M", None, None), position=20,
fields={"sku": "T-M", "variant_image": None}),
],
images=[],
)
}
result = _roundtrip_diff(catalog)
assert result.summary["unchanged"] == 1, result.records
assert result.summary["updates"] == 0
+222
View File
@@ -0,0 +1,222 @@
"""products service — drafts: validate/preview/discard (PUC-2/3/3a/5a; INV-11)."""
import json
import logging
import psycopg
import pytest
from app.domains import products
from app.platform import db
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\nstar-tee,Star Tee,Acme,24.00\n"
@pytest.fixture()
def migrated_conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
yield conn
@pytest.fixture()
def merchant(migrated_conn):
acct = migrated_conn.execute(
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
sf = migrated_conn.execute(
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
migrated_conn.execute(
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
migrated_conn.commit()
return {"account_id": acct, "storefront_id": sf}
def test_import_validate_creates_draft_with_summary(migrated_conn, merchant):
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
assert draft["dialect"] == "canonical"
assert draft["summary"] == {"adds": 2, "updates": 0, "unchanged": 0, "errors": 0}
assert draft["expires_at"]
def test_validate_writes_nothing_to_catalog_inv11(migrated_conn, merchant):
products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
assert migrated_conn.execute("SELECT count(*) FROM variant").fetchone()[0] == 0
def test_file_rejection_leaves_no_draft(migrated_conn, merchant):
with pytest.raises(products.FileRejected):
products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "bad.csv",
b"Vendor,Price\nAcme,1\n")
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
def test_records_paging_and_kind_filter(migrated_conn, merchant):
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
recs = products.get_draft_records(migrated_conn, merchant["storefront_id"], draft["id"])
assert [r["handle"] for r in recs] == ["moon-mug", "star-tee"]
adds = products.get_draft_records(
migrated_conn, merchant["storefront_id"], draft["id"], kind="add", limit=1)
assert len(adds) == 1 and adds[0]["kind"] == "add"
def test_discard_deletes_no_trace_puc3a(migrated_conn, merchant):
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"])
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) # idempotent
def test_draft_scoped_to_storefront_inv14(migrated_conn, merchant):
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
other_sf = migrated_conn.execute(
"INSERT INTO storefront (name) VALUES ('Other') RETURNING id").fetchone()[0]
migrated_conn.commit()
with pytest.raises(products.DraftNotFound):
products.get_draft(migrated_conn, other_sf, draft["id"])
def test_expired_draft_raises_and_lazily_deletes(migrated_conn, merchant):
draft = products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
migrated_conn.execute(
"UPDATE import_draft SET expires_at = now() - interval '1 minute' WHERE id = %s",
(draft["id"],))
migrated_conn.commit()
with pytest.raises(products.DraftExpired):
products.get_draft(migrated_conn, merchant["storefront_id"], draft["id"])
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
@pytest.fixture()
def telemetry_propagation():
"""create_app() sets propagate=False on the parent "ecomm" logger
(main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
root-logger handler whenever an API test ran first. Restore propagation here."""
lg = logging.getLogger("ecomm")
prior = lg.propagate
lg.propagate = True
yield
lg.propagate = prior
def test_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
products.import_validate(
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
assert any(
e["event"] == "import_draft_created" and e["adds"] == 2 and e["row_count"] == 2
and "duration_ms" in e and e["unknown_columns_count"] == 0
for e in events
)
UPDATE_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,21.00\nstar-tee,Star Tee,Acme,24.00\n"
MIXED_CSV = b"Handle,Title,Variant Price\ngood-mug,Mug,10.00\nbad-tee,Tee,not-a-price\n"
def _validate(conn, m, data=GOOD_CSV):
return products.import_validate(conn, m["storefront_id"], m["account_id"], "cat.csv", data)
def test_confirm_applies_adds_and_records_run(migrated_conn, merchant):
draft = _validate(migrated_conn, merchant)
run_id = products.confirm_draft(
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
assert run["products_added"] == 2 and run["status"] == "complete"
assert run["by"] == "m@example.com"
assert run["image_progress"] == {"done": 0, "total": 0} and run["image_outcomes"] == []
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
def test_confirm_update_changes_only_diffed_fields(migrated_conn, merchant):
d1 = _validate(migrated_conn, merchant)
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
assert d2["summary"] == {"adds": 0, "updates": 1, "unchanged": 1, "errors": 0}
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
price = migrated_conn.execute(
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
).fetchone()[0]
assert str(price) == "21.00"
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 # no dupes (BUC-3)
def test_confirm_blank_position_cell_round_trips(migrated_conn, merchant):
# A present-but-empty Variant Position cell resolves to file order at diff
# time — never SET position = NULL (which would abort the confirm on the
# NOT NULL constraint).
d1 = _validate(migrated_conn, merchant)
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
blank_position_csv = (
b"Handle,Title,Vendor,Variant Price,Variant Position\n"
b"moon-mug,Moon Mug,Acme,21.00,\n"
b"star-tee,Star Tee,Acme,24.00,\n"
)
d2 = _validate(migrated_conn, merchant, blank_position_csv)
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
price = migrated_conn.execute(
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
).fetchone()[0]
assert str(price) == "21.00"
def test_confirm_mixed_applies_valid_records_errors(migrated_conn, merchant):
draft = _validate(migrated_conn, merchant, MIXED_CSV)
run_id = products.confirm_draft(
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 1
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
assert run["rows_errored"] == 1
assert run["errors"][0]["column"] == "Variant Price"
def test_confirm_stale_fingerprint_409_inv11(migrated_conn, merchant):
draft = _validate(migrated_conn, merchant)
other = _validate(migrated_conn, merchant)
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], other["id"])
with pytest.raises(products.PreviewStale):
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
def test_confirm_nothing_to_apply_puc10(migrated_conn, merchant):
d1 = _validate(migrated_conn, merchant)
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
d2 = _validate(migrated_conn, merchant)
assert d2["summary"]["unchanged"] == 2
with pytest.raises(products.NothingToApply):
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
def test_runs_history_newest_first(migrated_conn, merchant):
d1 = _validate(migrated_conn, merchant)
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
r2 = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
runs = products.list_runs(migrated_conn, merchant["storefront_id"])
assert [r["id"] for r in runs][0] == r2
def test_summary_counts(migrated_conn, merchant):
assert products.summary(migrated_conn, merchant["storefront_id"]) == {
"product_count": 0, "image_problem_count": 0, "latest_run_id": None}
d = _validate(migrated_conn, merchant)
rid = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d["id"])
s = products.summary(migrated_conn, merchant["storefront_id"])
assert s == {"product_count": 2, "image_problem_count": 0, "latest_run_id": rid}
def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_propagation):
draft = _validate(migrated_conn, merchant)
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events)
+164
View File
@@ -0,0 +1,164 @@
"""§6.5.1 row validation — every row-error rule has a fixture (SD-0002 §6.8)."""
from decimal import Decimal
import pytest
from app.domains.products.codec import parse_csv
from app.domains.products.validate import build_products
def _products(*lines: str):
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
def _errors(*lines: str):
return [e for p in _products(*lines) for e in p.errors]
def test_simple_product_parses_clean():
[p] = _products(
"Handle,Title,Vendor,Tags,Status,Published,Variant Price,Variant SKU",
"moon-mug,Moon Mug,Acme,\"kitchen, mugs\",active,TRUE,18.00,SKU-1",
)
assert p.valid and p.handle == "moon-mug" and p.title == "Moon Mug"
assert p.fields["tags"] == ["kitchen", "mugs"]
assert p.fields["status"] == "active" and p.fields["published"] is True
[v] = p.variants
assert v.options == (None, None, None)
assert v.fields["price"] == Decimal("18.00") and v.fields["sku"] == "SKU-1"
def test_option_product_groups_consecutive_rows():
[p] = _products(
"Handle,Title,Option1 Name,Option1 Value,Variant Price",
"tee,Tee,Size,S,24.00",
"tee,,,M,24.00",
"tee,,,L,26.00",
)
assert p.valid and p.option_names == ("Size", None, None)
assert [v.options[0] for v in p.variants] == ["S", "M", "L"]
def test_image_only_rows_and_dedupe():
[p] = _products(
"Handle,Title,Image Src,Image Position,Image Alt Text",
"mug,Mug,https://x/a.jpg,1,front",
"mug,,https://x/b.jpg,2,back",
"mug,,https://x/a.jpg,3,dupe",
)
assert p.valid
assert [(i.source_url, i.position) for i in p.images] == [
("https://x/a.jpg", 1), ("https://x/b.jpg", 2),
]
def test_variant_image_joins_product_images():
[p] = _products(
"Handle,Title,Variant Image",
"mug,Mug,https://x/v.jpg",
)
assert p.variants[0].fields["variant_image"] == "https://x/v.jpg"
assert [i.source_url for i in p.images] == ["https://x/v.jpg"]
def test_description_sanitized_inv15():
[p] = _products(
"Handle,Title,Description",
'mug,Mug,"<p onclick=\'x()\'>hi</p><script>evil()</script>"',
)
html = p.fields["description_html"]
assert "<p>" in html and "script" not in html and "onclick" not in html
def test_blank_cell_clears_absent_column_missing():
[p] = _products("Handle,Title,Vendor", "mug,Mug,")
assert p.fields["vendor"] is None # present-but-empty == clear
assert "status" not in p.fields # absent column == untouched
@pytest.mark.parametrize(
"header,row,column,fragment",
[
("Handle,Title", ",NoHandle", "Handle", "needs a Handle"),
("Handle,Title", "Bad_Handle!,T", "Handle", "isn't a valid handle"),
("Handle,Title", "mug,", "Title", "missing its Title"),
("Handle,Title,Type", "mug,Mug,kit_virtual", "Type", "kits arrive"),
("Handle,Title,Component 1 SKU", "mug,Mug,ABC", "Component 1 SKU", "kits arrive"),
("Handle,Title,Status", "mug,Mug,live", "Status", "is not a status"),
("Handle,Title,Published", "mug,Mug,YES", "Published", "is not TRUE or FALSE"),
("Handle,Title,Variant Price", 'mug,Mug,"12,50"', "Variant Price", "is not a price"),
("Handle,Title,Variant Cost", "mug,Mug,-3", "Variant Cost", "is not a price"),
("Handle,Title,Variant Weight", "mug,Mug,heavy", "Variant Weight", "is not a number"),
("Handle,Title,Variant Inventory Qty", "mug,Mug,3.5", "Variant Inventory Qty", "is not a whole number"),
("Handle,Title,Variant Position", "mug,Mug,0", "Variant Position", "is not a position"),
("Handle,Title,Option1 Value", "mug,Mug,Red", "Option1 Value", "no Option1 Name"),
],
)
def test_row_error_rules(header, row, column, fragment):
errors = _errors(header, row)
assert any(e.column == column and fragment in e.message for e in errors), errors
def test_missing_option_value_for_named_option():
errors = _errors(
"Handle,Title,Option1 Name,Option1 Value,Variant SKU",
"tee,Tee,Size,S,A",
"tee,,,,B",
)
assert any("missing its Option1 Value" in e.message and e.line_number == 3 for e in errors)
def test_duplicate_option_combo_is_error():
errors = _errors(
"Handle,Title,Option1 Name,Option1 Value",
"tee,Tee,Size,S",
"tee,,,S",
)
assert any("duplicate variant" in e.message for e in errors)
def test_second_variant_on_no_option_product_is_error():
errors = _errors(
"Handle,Title,Variant SKU",
"mug,Mug,A",
"mug,,B",
)
assert any("without options can have only one variant" in e.message for e in errors)
def test_non_consecutive_handle_is_error():
errors = _errors(
"Handle,Title",
"mug,Mug",
"tee,Tee",
"mug,",
)
assert any("must be consecutive" in e.message and e.line_number == 4 for e in errors)
def test_no_data_row_is_error():
errors = _errors(
"Handle,Title,Variant SKU,Image Src",
"mug,Mug,A,",
"mug,,,",
)
assert any("no variant or image data" in e.message for e in errors)
def test_errors_poison_their_product_only():
products = _products(
"Handle,Title,Variant Price",
"good-mug,Mug,10.00",
"bad-tee,Tee,not-a-price",
)
by_handle = {p.handle: p for p in products}
assert by_handle["good-mug"].valid
assert not by_handle["bad-tee"].valid
def test_all_errors_collected_not_first_only():
[p] = _products(
"Handle,Title,Status,Variant Price",
"mug,,bogus,abc",
)
assert len(p.errors) == 3 # missing Title + bad status + bad price
+26
View File
@@ -0,0 +1,26 @@
from app.platform import session
SECRET = "test-secret"
def test_sign_then_verify_round_trips():
token = session.sign({"account_id": 7, "email": "m@example.com"}, SECRET)
assert session.verify(token, SECRET) == {"account_id": 7, "email": "m@example.com"}
def test_verify_rejects_tampered_payload():
token = session.sign({"account_id": 7}, SECRET)
payload_b64, _, sig = token.partition(".")
# Flip the payload but keep the old signature -> must be rejected.
forged = session.sign({"account_id": 8}, SECRET).split(".")[0] + "." + sig
assert session.verify(forged, SECRET) is None
def test_verify_rejects_wrong_secret():
token = session.sign({"account_id": 7}, SECRET)
assert session.verify(token, "other-secret") is None
def test_verify_rejects_garbage():
assert session.verify("not-a-token", SECRET) is None
assert session.verify("", SECRET) is None
+24
View File
@@ -0,0 +1,24 @@
"""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"
+154
View File
@@ -0,0 +1,154 @@
"""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"
@@ -0,0 +1,64 @@
"""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
+42
View File
@@ -0,0 +1,42 @@
# deployment.toml — generated by define-deployment (launch-app SPEC §5.1, §6.6).
# Derived from the One Name 'ecomm' (§3.3); edit the toml, re-import to reconcile (§3.2.1).
[app]
name = "ecomm"
repo = "wiggleverse/wiggleverse-ecomm"
gitea_host = "https://git.wiggleverse.org"
version_source = { kind = "file", path = "VERSION" }
gitea_read_secret_ref = "wiggleverse-ecomm/ecomm-gitea-read-token"
[vm]
name = "ecomm-ppe"
zone = "us-central1-a"
project = "wiggleverse-ecomm"
machine_type = "e2-micro"
disk_gb = 10
service_user = "ecomm"
install_dir = "/opt/ecomm"
systemd_unit = "ecomm.service"
tunnel_through_iap = true
gcloud_config = "wiggleverse-ecomm"
[edge]
domain = "ecomm-ppe.wiggleverse.org"
# ecomm's health endpoint is /healthz (SD-0001 §6.4); body carries {status, version}.
health_url = "https://ecomm-ppe.wiggleverse.org/healthz"
# No DATABASE_PATH: ecomm runs Cloud SQL PostgreSQL (SD-0001 D-7/D-8) — the DSN is the
# ECOMM_DATABASE_URL secret reference below, minted by launch-app's provision-datastore.
[overlay] # non-secret env, plaintext (guide §8)
APP_URL = "https://ecomm-ppe.wiggleverse.org"
ECOMM_MAILER = "smtp"
ECOMM_COOKIE_SECURE = "1"
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>"
[secrets] # REFERENCES only — never bytes (§8.3)
ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret"
ECOMM_DATABASE_URL = "wiggleverse-ecomm/ecomm-ppe-database-url"
ECOMM_SMTP_PASSWORD = "wiggleverse-ohm/ohm-rfc-app-smtp-password"
+79 -3
View File
@@ -4,8 +4,9 @@ Bringing an environment from **empty persistence** to "first merchant, first
storefront" through the product flows alone (SD-0001 BUC-5). Empty is a working state
(INV-1): the app applies its own schema migrations at startup; there is no seed step.
This document grows per environment. SLICE-1 ships the **localhost** section; the
pre-production and production sections land with SLICE-4.
This document grows per environment. SLICE-1 shipped the **localhost** section;
SLICE-4 adds **pre-production (PPE)**. The production section lands with the prod
stand-up.
## Localhost
@@ -61,4 +62,79 @@ environment starts from (BUC-5a).
`scripts/dev.sh` sets `ECOMM_DATABASE_URL` to the local compose DSN
(`postgresql://ecomm:ecomm@localhost:5432/ecomm`). No deployment shape is baked into
the app (INV-8); deployed environments supply this and other config from Secret
Manager (SLICE-4).
Manager (see PPE below).
## Pre-production (PPE)
PPE is stood up and deployed through the **launch-app / flotilla-core** suite only
(handbook §8.5 — provisioning is an operator-run gesture). The app's deployment
record is `deployment.toml` at this repo's root; flotilla-core consumes it.
### One-time provisioning (operator-run, in order)
1. **Cloud foundation**`scaffold-gcp-project`: the GCP project, its dedicated
`--no-activate` gcloud config, billing, core APIs, ADC quota pin.
2. **Managed database**`provision-datastore` (launch-app §6.6a, built for ecomm's
D-7/D-8): Cloud SQL for PostgreSQL 16, private-IP-only, automated backups +
point-in-time recovery; writes the full DSN to Secret Manager as
`<project>/ecomm-ppe-database-url`. Bound on the deployment record as
`ECOMM_DATABASE_URL`.
3. **Secrets** (references only — bytes go in via stdin, never through a session):
`ECOMM_SESSION_SECRET` (fresh random), `ECOMM_SMTP_PASSWORD` (the shared
Wiggleverse relay credential), and flotilla's own Gitea read token for this
private repo.
4. **VM + edge**`provision-vm`: the e2-micro, IAP-only SSH, nginx + Cloudflare
origin TLS, the systemd unit, DNS for `ecomm-ppe.wiggleverse.org`.
5. **Deployment record** — `flotilla-core deployment scaffold ecomm … -o
deployment.toml`, validate, commit it here, `deployment import --reconcile`.
### Deploy
Each release is a git tag `v<VERSION>` matching the repo-root `VERSION` file (the
pin). The one gesture, from the flotilla-core repo's venv:
```bash
CLOUDSDK_ACTIVE_CONFIG_NAME=ecomm .venv/bin/flotilla-core deploy ecomm
```
Nine phases, fail-stop; it ends by polling `https://ecomm-ppe.wiggleverse.org/healthz`
and requiring `status == "ok"` and `version == <target>`. Watch it yourself the same
way:
```bash
curl https://ecomm-ppe.wiggleverse.org/healthz
```
### Configuration surface (PPE values)
| Env var | Kind | Value |
| --- | --- | --- |
| `ECOMM_DATABASE_URL` | secret ref | `<project>/ecomm-ppe-database-url` (from provision-datastore) |
| `ECOMM_SESSION_SECRET` | secret ref | `<project>/ecomm-ppe-session-secret` |
| `ECOMM_SMTP_PASSWORD` | secret ref | the shared Wiggleverse relay credential |
| `ECOMM_MAILER` | overlay | `smtp` |
| `ECOMM_COOKIE_SECURE` | overlay | `1` |
| `ECOMM_SMTP_HOST` / `_PORT` / `_USER` / `_FROM` | overlay | the relay coordinates (non-secret) |
### The rehearsal (PUC-11)
From empty persistence: the deploy migrates the schema at startup (INV-7); then a
real sign-up → one-time code arriving by **real email** → create storefront → admin,
through the public flows alone. Record each rehearsal here when it happens.
**Rehearsals:**
- **2026-06-11 — PPE first bootstrap (v0.4.0, session 0024): ✅** First-ever ecomm
deploy (`flotilla-core deploy ecomm`, 9/9 phases green, deploys.id=40) onto a
freshly provisioned environment — project `wiggleverse-ecomm`, Cloud SQL
`ecomm-ppe-pg` (provisioned by launch-app `provision-datastore`, first run), VM
`ecomm-ppe`. The app self-migrated the empty database at startup; the operator
then walked sign-up → real emailed code (Gmail relay) → create storefront →
honestly-empty admin in a browser, through the public flows alone. Findings
captured: OTC email branding (#16); identity should outgrow ecomm
(engineering#49/#50).
## Production
Lands with the prod stand-up — the **identical gesture** on a prod deployment record
(BUC-5a): same provisioning skills, same deploy, same rehearsal.
+125
View File
@@ -0,0 +1,125 @@
# Operating ecomm
The framework-repo operator guide (SD-0002 DOC-1), started at SLICE-5. It covers
the app's operational surface — telemetry, runbooks, alert gestures, the E2E
gate. Per-deployment mechanics (deploy, secrets, VM access) live in the
deployment's flotilla docs and `deployment.toml`; environment bring-up is in
[`BOOTSTRAP.md`](./BOOTSTRAP.md).
## Products import/export ops (SD-0002, SLICE-5)
The §6.4 surface: a merchant uploads a catalog CSV (`POST
/api/products/imports`), the app validates it and stores an **import draft**
with a full preview (adds / updates / unchanged / errors), the merchant
confirms or cancels at the preview gate, and a confirm applies the previewed
diff as one **import run** recorded in the history (`/api/products/imports/runs`).
Caps and behavior to know (all enforced in code, not config):
- **Caps (INV-18):** ≤ 5,000 data rows and ≤ 10 MB per file —
`MAX_DATA_ROWS` / `MAX_FILE_BYTES` in `backend/app/domains/products/models.py`,
enforced in `backend/app/domains/products/codec.py` (file-level rejection)
plus a 413 `file_too_large` guard in the BFF (`backend/app/main.py`).
- **Draft expiry:** ~1 hour (`expires_at = now() + interval '1 hour'`). Cleanup
is a lazy sweep — expired drafts are deleted on the next upload and on any
access to an expired draft; there is no background job to babysit.
- **Upsert-only (INV-10):** an import adds and updates, never deletes. Catalog
products/variants/images absent from the file are untouched.
- **One-transaction apply (INV-11):** a confirm applies the whole previewed
diff in a single DB transaction — it lands completely or not at all.
### Telemetry
Structured JSON events on the `ecomm.telemetry` logger
(`backend/app/platform/telemetry.py`), one JSON object per line, emitted from
`backend/app/domains/products/service.py`. The app's `ecomm.*` log handler
writes to the process's stderr, which journald captures on the VM (and Cloud
Logging where the agent ships it). Events carry counts and durations only —
never file names, URLs, catalog content, or secret bytes.
| Event | Trigger | Payload fields |
| --- | --- | --- |
| TEL-1 `import_draft_created` | validation completes, draft stored | `storefront_id, dialect, row_count, adds, updates, unchanged, errors, unknown_columns_count, duration_ms` |
| TEL-2 `import_run_completed` | apply transaction commits | `run_id, storefront_id, added, updated, errored, duration_ms` |
| TEL-3 `catalog_exported` | export stream completes | `storefront_id, status_filter, product_count, duration_ms` |
| TEL-6 `import_apply_failed` | apply transaction aborts unexpectedly | `draft_id, storefront_id, error_class` |
### Export (PUC-9, SLICE-6)
`GET /api/products/export?status=all|active|draft|archived` streams the
storefront's catalog as a canonical-format CSV (one codec, two directions — the
same format the importer parses). It is **read-only** (no draft, no run) and
storefront-scoped (INV-14). An empty catalog — no products, or none matching the
status filter — returns `409 empty_catalog`; the Products page disables the
Export action with a note in that case. The round-trip is lossless (INV-12):
re-importing an unmodified export previews as all-unchanged with the import
action disabled (PUC-10). TEL-3 (`catalog_exported`) is emitted once the stream
completes — counts and duration only, never catalog content.
### RB-2 — import apply failed
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
rolled back.
1. **Locate the failure.** On the VM, filter the journal for the event and note
the `draft_id`, `storefront_id`, and `error_class`:
```
journalctl -u ecomm.service | grep import_apply_failed
```
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
failure leaves the catalog exactly as it was: the storefront's runs history
(`GET /api/products/imports/runs`) shows **no new run**, and the products
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
3. **The merchant's draft is intact.** A failed apply does not consume the
draft — the merchant can retry confirm, or re-upload if the draft has since
expired (~1 h). Advise accordingly.
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
and the surrounding log context (the traceback is in the app log next to
the event).
### ALR-2 — the log-based alert (one gesture per environment)
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
```
# Select the deployment's gcloud config for this one process (handbook §8.4).
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
# Log-based metric counting import-apply failures (TEL-6).
gcloud logging metrics create ecomm_import_apply_failed \
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
```
Then attach an alert policy to the metric — **operator email channel, threshold
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
policy is created in the Cloud Console or with
`gcloud alpha monitoring policies create`; the exact command depends on the
notification-channel id, so list channels first:
```
# Find the operator email channel's id for the policy.
gcloud beta monitoring channels list
```
### E2E browser suite
- Lives at `e2e/` — Playwright, Chromium, six scenarios (SLICE-5:
preview/confirm happy path, actionable errors, file rejection, cancel;
SLICE-6: `e2e_export_download`, `e2e_roundtrip_noop`).
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
database** against the local compose Postgres and serves the built SPA from
the backend on **:8765** (the deployed topology), so it needs the dev
Postgres up (`scripts/dev.sh`).
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
the §9 pipeline (the PPE browser run is still manual this slice).
## Cross-references
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
+147
View File
@@ -0,0 +1,147 @@
# products domain — developer notes
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
SLICE-68. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
the spec is SD-0002 in the content repo.
## Layout and pipeline
`backend/app/domains/products/` is layered like the rest of the app
(main → domains → platform, enforced by import-linter):
- `models.py` — canonical row model + the column registry.
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
- `diff.py` — catalog × canonical products → apply plan + preview records.
- `serialize.py` — the export half: `CatalogProduct` snapshot → canonical CSV
(the inverse of `codec`/`validate`). DB-free, like `diff.py`.
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
Never commits or rolls back.
- `service.py` — use-case orchestration; owns every transaction boundary and
emits the TEL events via `backend/app/platform/telemetry.py`.
```mermaid
flowchart LR
subgraph validate_path [import_validate]
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
C[load_catalog] --> D
end
subgraph confirm_path [confirm_draft]
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
G -- no --> I[PreviewStale<br/>draft kept]
end
```
Confirm re-derives everything from the draft's stored `file_bytes` against the
live catalog, then checks the fingerprint — so what lands is exactly what the
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
adds and no updates refuses with `nothing_to_apply`.
## Canonical model and blank-vs-absent
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
is the registry header detection, unknown-column warnings, and validation all
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
(`status`, `published`, `product_type`, `tags`).
The §6.5.1 cell semantics, as implemented:
- **Absent column** → the field never enters `fields{}` → untouched by diff
and apply (never a change).
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
exists.
- **The position exception:** a cleared `Variant Position` has no
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
variant's 1-based file order within its product, never to NULL.
Option names live both in `CanonicalProduct.option_names` (the values) and in
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
distinction).
## Error granularity
`validate.py` never raises on a row problem: every violation is recorded as a
merchant-language `RowError` and **poisons its whole product block** — the
product previews as `kind="error"` and is excluded from apply, while parsing
continues so one pass yields a complete accounting (BUC-1a). On apply, error
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
the **error-row count**, while the preview's errors tile counts error
**products** — the two numbers legitimately differ.
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
created. The BFF adds an early 413 for oversized uploads (`main.py`).
## INV-11 mechanics
`compute_diff` makes **one walk** that produces two views of the same
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
stored as draft JSONB and served verbatim to the SPA. Because both derive from
the same walk they cannot diverge. The `fingerprint` is
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
product/variant/image writes, error rows, draft delete — is one transaction;
any exception rolls it back and emits TEL-6.
## Export & the round-trip (SLICE-6)
`serialize.py` is the export half of "one codec, two directions": it turns the
`CatalogProduct` snapshot (the same one `repo.load_catalog` builds for the diff
engine) back into canonical CSV, writing exactly the columns `codec.py` /
`validate.py` parse, in the §6.5.1 row grammar — `HEADER` is the full canonical
column set; product fields + option names sit on the first row; one variant per
row; images interleave; a product with more images than variants emits
image-only rows.
`repo.export_catalog` returns the status-filtered snapshot list (sorted by
handle, deterministic); the `service.export_catalog` generator streams
`serialize.catalog_to_csv` over it and emits TEL-3 (`catalog_exported`) once the
stream is exhausted. The BFF wraps it in a `StreamingResponse`; an empty
(filtered) catalog raises `EmptyCatalog` **eagerly**`409 empty_catalog`
before any bytes stream.
**INV-12** (`diff(catalog, import(export(catalog))) = ∅`) is locked two ways: a
property test (`test_products_serialize.py`) runs the real
export→parse→diff loop over 200 generated **text-field** catalogs and asserts
every product is `unchanged`; the `e2e_roundtrip_noop` browser scenario does the
same through the UI (export download → re-upload → all-unchanged preview, import
disabled). Numeric/`Decimal` fields — the property test's deliberate blind spot
(string-form vs value-identity) — get explicit round-trip unit tests.
## Named seams (what later slices replace)
- **`import_draft.file_bytes` → objectstore key (SLICE-7).** The upload
currently lives as BYTEA on the draft row (`0002_products.sql`); SLICE-7
moves the bytes to object storage and stores a key.
- **`codec.detect_dialect` → Shopify (SLICE-8).** Today it always returns
`"canonical"`; SLICE-8 recognizes Shopify's exact header set here (INV-17).
- **Run-status complete shortcut → `fetching_images` (SLICE-7).**
`confirm_draft` inserts the run with `status="complete"` directly; SLICE-7
inserts it as `fetching_images` and hands off to the image-fetch task.
Relatedly, image rows are created with the schema default
`status='pending'` **today and stay pending** — placeholder behavior until
SLICE-7's fetch phase (the run-detail payload already carries the stable
`image_progress` / `image_outcomes` shape, zeroed).
## Test map
| File | Covers |
| --- | --- |
| `backend/tests/test_products_codec.py` | file-level gates: parse, caps (INV-18), required columns, dialect |
| `backend/tests/test_products_validate.py` | every §6.5.1 row-error rule, one fixture each |
| `backend/tests/test_products_diff.py` | classification, blank-vs-absent, option matching, fingerprint |
| `backend/tests/test_products_serialize.py` | serializer grammar + INV-12 property test (round-trip no-op over generated catalogs) + decimal round-trip |
| `backend/tests/test_products_export.py` | status-filtered snapshot, streamed export, EmptyCatalog, TEL-3 |
| `backend/tests/test_products_service.py` | draft lifecycle: validate/preview/discard, expiry, TEL-1 |
| `backend/tests/test_products_invariants.py` | INV-10 (never deletes), INV-14 (storefront isolation), apply transactionality, TEL-6 |
| `backend/tests/test_products_endpoints.py` | §6.4 API scenarios + auth/storefront gates |
| `e2e/tests/import-preview-confirm.spec.ts` | happy path: upload → preview → confirm → history |
| `e2e/tests/import-errors.spec.ts` | actionable row errors at preview and on the run report |
| `e2e/tests/import-file-rejected.spec.ts` | file-level rejection, picker stays live, no trace |
| `e2e/tests/import-cancel.spec.ts` | cancel at preview leaves no trace |
| `e2e/tests/export-download.spec.ts` | export downloads canonical CSV, status filter respected |
| `e2e/tests/roundtrip-noop.spec.ts` | export → re-import → all-unchanged, import disabled (PUC-10) |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,55 @@
# SLICE-4 (part 1) — Deploy-Contract Code Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make ecomm deployable by flotilla-core's 9-phase gesture and rehearsable on PPE: versioned health, SPA served by the backend, real `SmtpMailer` with honest delivery failure (closes ecomm#7), per SD-0001 §7.2 (SLICE-4) — the code half. Provisioning (Cloud SQL via the new launch-app `provision-datastore`, VM, deployment.toml) and the PPE rehearsal follow as operator gestures in the same session.
**Anchor:** SD-0001 §7.2 SLICE-4 (R2a, checked this session). flotilla contract: flotilla-core SPEC §8.1 (checkout `v<VERSION>` tag → pip → `npm ci && npm run build` → write `backend/.env` → restart → verify `body.version == target && body.status == "ok"`); provision-vm: nginx proxies ALL routes to uvicorn `app.main:app` (WorkingDirectory `backend/`), so the backend must serve `frontend/dist`.
**Architecture:** `VERSION` at repo root is the single version source (healthz body + FastAPI version + the deploy pin). `create_app()` mounts `frontend/dist` (when present) after all API routes — SPA fallback via `StaticFiles(html=True)`. `SmtpMailer` is the second adapter of the existing mailer port (STARTTLS smtplib, config from `ECOMM_SMTP_*`, INV-8); delivery failure raises `MailerError``accounts.request_code` sends **before** commit (rollback on failure — no orphan code, no tripped cooldown; ecomm#7) → BFF surfaces `502 delivery_failed` (INV-9).
**Tech Stack:** stdlib `smtplib`/`email.message`; FastAPI `StaticFiles`; no new dependencies.
---
### Task 1: VERSION + versioned /healthz
**Files:** Create `VERSION` (root). Modify `backend/app/main.py`, `backend/tests/test_healthz.py`.
- [ ] Write `VERSION` containing `0.4.0`.
- [ ] Test first: `test_healthz_ok_on_migrated_empty_db` asserts `{"status": "ok", "version": "0.4.0"}` read from the VERSION file (compare against `(repo_root/"VERSION").read_text().strip()`, not a literal). Run → FAIL.
- [ ] `main.py`: add `_APP_VERSION = (Path(__file__).resolve().parents[2] / "VERSION").read_text().strip()` (fallback `"0.0.0"` when missing); healthz returns `{"status": "ok", "version": _APP_VERSION}`; `FastAPI(version=_APP_VERSION)`. Run → PASS. Commit.
### Task 2: backend serves the SPA (deploy phase-8 contract)
**Files:** Modify `backend/app/main.py`. Test `backend/tests/test_static_spa.py`.
- [ ] Test first: `create_app(database_url=..., static_dir=tmp_path)` with a `tmp_path/index.html`; GET `/` → 200 + the html; GET `/healthz` and `/api/auth/me` still answer JSON (API wins over the mount). Default `static_dir=None` → resolves `repo_root/frontend/dist`, skipped silently when absent (dev: Vite serves). Run → FAIL.
- [ ] `create_app(database_url=None, static_dir: str | Path | None = None)`; after the last route: resolve dir, `if dir/index.html exists: app.mount("/", StaticFiles(directory=dir, html=True), name="spa")`. Run → PASS (whole suite). Commit.
### Task 3: SmtpMailer + config surface (INV-8)
**Files:** Modify `backend/app/platform/{mailer,config}.py`. Test `backend/tests/test_mailer.py` (extend).
- [ ] Config additions: `smtp_host()` (`ECOMM_SMTP_HOST`), `smtp_port()` (`ECOMM_SMTP_PORT`, 587), `smtp_user()`, `smtp_password()`, `smtp_from()` (default = user), `smtp_starttls()` (default on).
- [ ] Tests first: `MailerError` exists; `build_mailer("smtp")` returns `SmtpMailer` wired from env (monkeypatched); `SmtpMailer.send` drives a monkeypatched `smtplib.SMTP` (starttls → login → send_message with To/Subject/From + body) and never logs the body; SMTP exception → `MailerError`. Run → FAIL.
- [ ] Implement: `MailerError(Exception)`; `SmtpMailer` (EmailMessage; `smtplib.SMTP(host, port, timeout=10)`, STARTTLS per config, login when user set, `send_message`; `except Exception → raise MailerError`; logs only `smtp sent to=<sha256[:8] of recipient>` — §6.6 log hygiene); `build_mailer("smtp")` builds it from config. Run → PASS. Commit.
### Task 4: honest delivery failure — send-before-commit + 502 (closes ecomm#7)
**Files:** Modify `backend/app/domains/accounts/{errors,service,__init__}.py`, `backend/app/main.py`. Test `backend/tests/test_accounts_request_code.py` + `test_auth_endpoints.py` (extend).
- [ ] Tests first: a mailer whose `send` raises `MailerError` → service raises `accounts.DeliveryFailed`, **no `auth_code` row remains**, and an immediate retry is **not** cooldown-blocked; endpoint test: 502 `{"error": {"code": "delivery_failed"}}`. Run → FAIL.
- [ ] Implement: `DeliveryFailed(AccountsError)`; `request_code` moves `conn.commit()` **after** `mailer.send(...)`, wrapping send in `try/except MailerError → conn.rollback(); raise DeliveryFailed`; BFF maps it to `_error(502, "delivery_failed", "We couldn't send the code — try again.")`. Run → PASS (whole backend suite). Commit.
### Task 5: BOOTSTRAP.md PPE section + housekeeping + gate
**Files:** Modify `docs/BOOTSTRAP.md`, `README.md`, `frontend/package.json` (0.4.0).
- [ ] BOOTSTRAP.md: replace the "land with SLICE-4" sentence; add **Pre-production (PPE)** section — prerequisites (suite-run provisioning: scaffold-gcp-project → provision-datastore (Cloud SQL, engineering#46) → provision-vm → define-deployment/import; secrets as references), the one deploy gesture (`CLOUDSDK_ACTIVE_CONFIG_NAME=<config> flotilla-core deploy <name>`), how to watch `/healthz`, the rehearsal walk (PUC-11), reset-to-empty note; Prod section: placeholder "lands with the prod stand-up" honestly.
- [ ] README status: deploy contract in place; frontend package 0.4.0.
- [ ] `./scripts/check.sh` → all green. Commit. Push, PR citing SD-0001 §7.2 SLICE-4 + ecomm#7, merge, **tag `v0.4.0` on the merge commit and push the tag** (the deploy pin).
## Self-review
Spec coverage: SmtpMailer + config wiring INV-8 ✓ (T3), §6.6 hardening — Secure cookies already config-driven, log hygiene ✓ (T3 no-body logging; LogMailer is dev-only by config), `deployment.toml` + provisioning deliberately deferred to the Phase-C suite gestures (needs the GCP project id that scaffold-gcp-project mints), BOOTSTRAP.md PPE ✓ (T5), versioned health for the §8.1 verify ✓ (T1), SPA serving for the §2 topology ✓ (T2), ecomm#7 ✓ (T4). E2E browser tests still deferred per §6.8. Type consistency: `MailerError` lives in platform/mailer; `DeliveryFailed` in accounts errors; both exported via package surfaces.
@@ -0,0 +1,118 @@
# ui/designs Content-Repo Collection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Establish `ui/designs/` as a standard content-repo collection (alongside `specs/` and `plans/`) — concretely in `wiggleverse-ecomm-content`, and centrally in the engineering repo's schema docs so it binds all `*-content` repos.
**Architecture:** Docs/convention change only, two repos, one PR each. The collection convention's canonical home is the engineering repo's `schemas/` docs (the `content` descriptor description strings in `app.schema.json` + `schemas/README.md` changelog), so the central change is a docs-only minor schema bump (1.2 → 1.3). No tooling changes: the spec-linkage gate, backfill verb, and GUIDE/TEMPLATE Design field are tracked separately as `wiggleverse-dev-claude-plugin#93`.
**Tech Stack:** Markdown, JSON Schema (description strings only), git + Gitea PRs over SSH.
**Anchor:** `wiggleverse/wiggleverse-ecomm#8` (type/task, ELIGIBLE R2b). Related: `wiggleverse/wiggleverse-dev-claude-plugin#93`.
---
### Task 1: `ui/designs/` collection in wiggleverse-ecomm-content
**Files:**
- Create: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/ui/designs/README.md`
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content/README.md` (layout table, lines 1014)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/wiggleverse-ecomm-content checkout -b ui-designs-collection
```
- [ ] **Step 2: Create the collection README**
`ui/designs/README.md`:
```markdown
# ui/designs — UI-design artifacts
Standard content-repo collection (alongside `specs/` and `plans/`) holding this
app's UI-design artifacts — primarily Claude Design outputs generated from a
Solution Design (rubric: `engineering/solution-design/claude-design-vs-code.md`).
A Solution Design with a UX-involving slice references its design artifact here
by path. The spec-linkage gate and the backfill gesture for adding that
reference once a design exists are tracked in
`wiggleverse/wiggleverse-dev-claude-plugin#93`.
Suggested layout: one subfolder per design, named for the spec/slice it serves,
e.g. `ui/designs/SD-0001-slice-3-storefront/`.
```
- [ ] **Step 3: Add the layout-table row**
In the top-level `README.md`, extend the table:
```markdown
| Path | Holds |
| --- | --- |
| `specs/` | reviewed Solution-Design specs (submitted at session finalize) |
| `plans/` | archived implementation plans |
| `ui/designs/` | UI-design artifacts (Claude Design outputs), referenced from specs |
```
- [ ] **Step 4: Commit, push, PR, merge**
```bash
git -C …/wiggleverse-ecomm-content add ui/designs/README.md README.md
git -C …/wiggleverse-ecomm-content commit -m "content: add ui/designs/ collection (ecomm#8)"
git -C …/wiggleverse-ecomm-content push -u origin ui-designs-collection
```
PR via Gitea API (default per-host token, NOT the issue-scoped one — TOKENS.md), then merge; body cites `wiggleverse/wiggleverse-ecomm#8` + plugin `#93`.
### Task 2: Standardize centrally in engineering schemas docs
**Files:**
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json` (lines 13, 94, 181, 184)
- Modify: `/Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/README.md` (repos[] bullets + changelog)
- [ ] **Step 1: Branch**
```bash
git -C /Users/benstull/git/wiggleverse.org/wiggleverse/engineering checkout -b ui-designs-collection
```
- [ ] **Step 2: Schema description strings + enum**
1. `schemaVersion.enum`: `["1.0", "1.1", "1.2"]``["1.0", "1.1", "1.2", "1.3"]`
2. `content` property description (line 94): "…where this app's reviewed specs/ and archived plans/ collections live…" → "…where this app's reviewed specs/, archived plans/, and ui/designs/ collections live…"
3. `$defs.content` description (line 181): "(reviewed specs/, archived plans/)" → "(reviewed specs/, archived plans/, ui/designs/ UI-design artifacts)"; and "The specs/ and plans/ collection subdirs are appended by the submit tooling" → "The specs/, plans/, and ui/designs/ collection subdirs are conventions (specs/ and plans/ are appended by the submit tooling; ui/designs/ holds Claude Design outputs referenced from specs)"
4. `$defs.content.subdir` description (line 184): "under which the specs/ and plans/ collections live" → "under which the specs/, plans/, and ui/designs/ collections live"
- [ ] **Step 3: schemas/README.md**
Changelog entry above 1.2:
```markdown
- **1.3** — docs-only: the content-repo collection convention gains a third
standard collection, `ui/designs/` — UI-design artifacts (Claude Design
outputs generated from a Solution Design), referenced from specs. Like
`specs/`/`plans/`, the subdir is a convention, not a schema field; no
validation change (the spec-linkage gate/backfill tooling is
wiggleverse-dev-claude-plugin#93). Existing files stay valid.
```
If the repos[] bullet list documents the `content` descriptor, name the three collections there too; if 1.2 never added a `content` bullet, add one.
- [ ] **Step 4: Validate JSON, commit, push, PR, merge**
```bash
python3 -m json.tool /Users/benstull/git/wiggleverse.org/wiggleverse/engineering/schemas/app.schema.json > /dev/null && echo OK
git -C …/engineering add schemas/app.schema.json schemas/README.md
git -C …/engineering commit -m "schemas: 1.3 — ui/designs/ standard content collection (ecomm#8, plugin#93)"
git -C …/engineering push -u origin ui-designs-collection
```
PR + merge (default token for `/pulls`).
### Task 3: Cross-link and close out
- [ ] **Step 1: Comment on plugin #93** noting the location is now standard (link both merged PRs) — its gate/backfill work can assume `ui/designs/` exists.
- [ ] **Step 2: Close ecomm#8** with a comment naming both merged PRs.
- [ ] **Step 3: Checkpoint the transcript** (`publish-transcript.sh` on the `--INPROGRESS` file).
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.backend.log
test-results/
playwright-report/
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
star-tee,,,,,,,M,WG-TEE-M,24.00,18
1 Handle Title Vendor Tags Status Published Option1 Name Option1 Value Variant SKU Variant Price Variant Inventory Qty
2 moon-mug Moon Mug Wiggle Goods kitchen, mugs active TRUE WG-MUG-001 18.00 40
3 star-tee Star Tee Wiggle Goods apparel active TRUE Size S WG-TEE-S 24.00 12
4 star-tee M WG-TEE-M 24.00 18
+2
View File
@@ -0,0 +1,2 @@
Handle,Vendor
mug,Acme
1 Handle Vendor
2 mug Acme
+4
View File
@@ -0,0 +1,4 @@
Handle,Title,Variant Price
good-mug,Good Mug,10.00
bad-tee,Bad Tee,not-a-price
also-good,Also Good,5.00
1 Handle Title Variant Price
2 good-mug Good Mug 10.00
3 bad-tee Bad Tee not-a-price
4 also-good Also Good 5.00
+110
View File
@@ -0,0 +1,110 @@
// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page
// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens
// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx).
import { expect, type Page } from "@playwright/test";
import { readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
const LOG = join(__dirname, ".backend.log");
let seq = 0;
// The storefront name every helper-created merchant uses; tests assert against it.
export const STOREFRONT_NAME = "E2E Test Goods";
export function freshEmail(): string {
return `merchant${Date.now()}-${seq++}@example.com`;
}
// LogMailer's exact line (backend/app/platform/mailer.py):
// INFO:ecomm.mailer: LogMailer -> <to> | Your ecomm code: <6 digits>\n<body>
// The first 6-digit group after the marker is the code.
async function codeFor(email: string): Promise<string> {
for (let i = 0; i < 50; i++) {
const log = await readFile(LOG, "utf8").catch(() => "");
const at = log.lastIndexOf(`LogMailer -> ${email}`);
if (at >= 0) {
const m = log.slice(at).match(/\b(\d{6})\b/);
if (m) return m[1];
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`no code logged for ${email}`);
}
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
// Landing → the sign-up door.
await page.goto("/");
await page.getByRole("button", { name: "Create your storefront →" }).click();
// Sign-in step 1: request the one-time code.
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Send code" }).click();
// Sign-in step 2: read the code from the backend log and verify it.
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
const code = await codeFor(email);
await page.getByLabel("One-time code").fill(code);
await page.getByRole("button", { name: "Continue" }).click();
// Create-storefront screen (a new account has none yet).
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
// Admin shell: nav strip + the storefront identity in the topbar.
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
return email;
}
export async function gotoProducts(page: Page) {
await page
.getByRole("navigation", { name: "Admin sections" })
.getByRole("link", { name: "Products" })
.click();
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
}
export async function uploadFixture(page: Page, fixture: string) {
// Two "Import products" links render on the empty products page (header + empty
// state) — same destination, so take the first.
await page.getByRole("link", { name: "Import products" }).first().click();
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
}
// Import good.csv and confirm it, leaving a 2-product catalog. Returns nothing;
// callers continue from the run-detail screen.
export async function importGoodCsv(page: Page) {
await uploadFixture(page, "good.csv");
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
await page.getByRole("button", { name: "Import 2 products" }).click();
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
}
// Click an Export status option and capture the downloaded CSV's text + path.
export async function exportCatalog(page: Page, label: string): Promise<{ text: string; path: string }> {
// Open the <details> menu, then click the status option, capturing the download.
// The menu is a native <details> that *toggles* on each summary click and is
// NOT closed by clicking a download <a> (a download doesn't navigate). So on a
// second export the menu may already be open — deterministically open it
// rather than blind-toggling, and wait for the status link to be visible.
const details = page.locator("details.products__export");
if (!(await details.evaluate((el: HTMLDetailsElement) => el.open))) {
await details.locator("> summary").click();
}
const link = page.getByRole("link", { name: label, exact: true });
await expect(link).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent("download"),
link.click(),
]);
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
const text = Buffer.concat(chunks).toString("utf8");
const path = join(tmpdir(), `export-${Date.now()}.csv`);
await writeFile(path, text, "utf8");
return { text, path };
}
+76
View File
@@ -0,0 +1,76 @@
{
"name": "wiggleverse-ecomm-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wiggleverse-ecomm-e2e",
"devDependencies": {
"@playwright/test": "^1.48.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"name": "wiggleverse-ecomm-e2e",
"private": true,
"scripts": { "test": "playwright test" },
"devDependencies": { "@playwright/test": "^1.48.0" }
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
timeout: 60_000,
retries: 0,
// One shared backend + log file; parallel sign-ins would interleave codes.
workers: 1,
use: { baseURL: "http://localhost:8765" },
webServer: {
command: "bash ./serve.sh",
url: "http://localhost:8765/healthz",
reuseExistingServer: false,
timeout: 120_000,
},
});
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
( cd "$repo_root/frontend" && npm run build )
fi
"$repo_root/.venv/bin/python" - <<'PY'
import psycopg
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
admin.execute("CREATE DATABASE ecomm_e2e")
PY
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
export ECOMM_MAILER=log
cd "$repo_root/backend"
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
> "$repo_root/e2e/.backend.log" 2>&1
+21
View File
@@ -0,0 +1,21 @@
// DoD scenario e2e_export_download (SD-0002 §6.8): export downloads a canonical
// CSV and the status filter is respected.
import { expect, test } from "@playwright/test";
import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
test("e2e_export_download", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await importGoodCsv(page);
await gotoProducts(page);
// Export "All products" → a canonical CSV with both handles.
const all = await exportCatalog(page, "All products");
expect(all.text.split("\n")[0]).toContain("Handle,");
expect(all.text).toContain("moon-mug");
expect(all.text).toContain("star-tee");
// good.csv's products are active → "Active" exports both, "Draft" is empty/disabled-path.
const active = await exportCatalog(page, "Active");
expect(active.text).toContain("moon-mug");
});
+23
View File
@@ -0,0 +1,23 @@
// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the
// preview gate (PUC-3a) leaves no trace — no products, no run in the history.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
test("e2e_import_cancel_no_trace", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await uploadFixture(page, "good.csv");
// The preview gate is up.
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
// Lands back on Products — still the empty state, no run recorded.
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
await expect(
page.getByText("No products yet. Bulk import is how product data gets in."),
).toBeVisible();
await expect(page.getByText("No imports yet.")).toBeVisible();
});
+41
View File
@@ -0,0 +1,41 @@
// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are
// actionable — line, column, message — at the preview gate AND on the run report
// card (PUC-5), while the good rows still import.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
test("e2e_import_errors_actionable", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await uploadFixture(page, "mixed-errors.csv");
// Preview tiles: two good products, one errored row.
await expect(
page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
await page.getByRole("button", { name: "1 errors" }).click();
// The error table names the line, the column, and the problem (actionable, PUC-5).
const previewRow = page.locator(".errortable tbody tr");
await expect(previewRow).toHaveCount(1);
await expect(previewRow.locator("td").nth(0)).toHaveText("3");
await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price");
await expect(previewRow.locator("td").nth(2)).toContainText("is not a price");
// Good rows still import.
await page.getByRole("button", { name: "Import 2 products" }).click();
// Run detail: counts include the errored row, and the same error row persists.
await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible();
await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible();
const runRow = page.locator(".errortable tbody tr");
await expect(runRow).toHaveCount(1);
await expect(runRow.locator("td").nth(0)).toHaveText("3");
await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price");
await expect(runRow.locator("td").nth(2)).toContainText("is not a price");
// The catalog gained the two good products.
await gotoProducts(page);
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
});
+25
View File
@@ -0,0 +1,25 @@
// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection
// (PUC-5a) renders in place on the upload screen, the picker stays live for a
// retry, and nothing is recorded — no products, no run.
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
test("e2e_import_file_rejected", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await uploadFixture(page, "missing-title.csv");
// The rejection renders in place, naming the missing column.
await expect(page.getByText("That file can't be imported")).toBeVisible();
await expect(page.getByText("missing the required column 'Title'")).toBeVisible();
// The picker is live again for a retry.
await expect(page.locator('input[type="file"]')).toBeEnabled();
// No trace: still the empty catalog, and no run recorded.
await gotoProducts(page);
await expect(
page.getByText("No products yet. Bulk import is how product data gets in."),
).toBeVisible();
await expect(page.getByText("No imports yet.")).toBeVisible();
});
+49
View File
@@ -0,0 +1,49 @@
// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to
// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm,
// run report card, and the catalog + history reflecting the import. Subsumes the
// Task-15 harness smoke (sign-up journey + products empty state).
import { expect, test } from "@playwright/test";
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers";
test("e2e_import_preview_confirm", async ({ page }) => {
await signUpWithStorefront(page);
// Admin topbar: storefront identity + the signed-in account chip (ex-smoke).
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
await gotoProducts(page);
// Empty state + the import affordances (SD-0002 §5.2, ex-smoke).
await expect(
page.getByText("No products yet. Bulk import is how product data gets in."),
).toBeVisible();
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
await uploadFixture(page, "good.csv");
// Preview (§5.4): summary tiles + the file's name in the heading.
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
// Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU).
const starTee = page.locator(".difflist__item", { hasText: "star-tee" });
await starTee.locator("summary").click();
await expect(starTee.getByText("WG-TEE-S")).toBeVisible();
// Consent gate (PUC-3): confirm the import.
await page.getByRole("button", { name: "Import 2 products" }).click();
// Run detail (§5.5): report card with the file name, counts, and status.
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible();
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
// Back on Products: the catalog count and exactly one history row for this run.
await gotoProducts(page);
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
const rows = page.locator(".datatable tbody tr");
await expect(rows).toHaveCount(1);
await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible();
});
+30
View File
@@ -0,0 +1,30 @@
// DoD scenario e2e_roundtrip_noop (SD-0002 §6.8, PUC-10): exporting then
// re-importing the unmodified file previews as all-unchanged with the import
// action disabled and the "Nothing to change" note.
import { expect, test } from "@playwright/test";
import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
test("e2e_roundtrip_noop", async ({ page }) => {
await signUpWithStorefront(page);
await gotoProducts(page);
await importGoodCsv(page);
await gotoProducts(page);
// Export the catalog, then re-import the unmodified file.
const { path } = await exportCatalog(page, "All products");
await page.getByRole("link", { name: "Import products" }).first().click();
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
await page.locator('input[type="file"]').setInputFiles(path);
// Preview: everything unchanged, nothing to add/update (PUC-10).
await expect(page.getByRole("button", { name: "2 unchanged" })).toBeVisible();
await expect(page.getByRole("button", { name: "0 to add" })).toBeVisible();
await expect(page.getByRole("button", { name: "0 to update" })).toBeVisible();
// The import action is disabled, with the no-op note.
const importBtn = page.getByRole("button", { name: /^Import 0 products$/ });
await expect(importBtn).toBeDisabled();
await expect(
page.getByText("Nothing to change — your catalog already matches this file"),
).toBeVisible();
});
+3
View File
@@ -3,6 +3,9 @@
<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>
+406 -3
View File
@@ -1,12 +1,12 @@
{
"name": "wiggleverse-ecomm-frontend",
"version": "0.1.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wiggleverse-ecomm-frontend",
"version": "0.1.0",
"version": "0.6.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -16,7 +16,8 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
},
"node_modules/@babel/code-frame": {
@@ -1239,6 +1240,129 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz",
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.1.9",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.12"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz",
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.1.9",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz",
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"magic-string": "^0.30.12",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz",
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz",
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.1.9",
"loupe": "^3.1.2",
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.35",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
@@ -1286,6 +1410,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001797",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
@@ -1307,6 +1441,33 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1339,6 +1500,16 @@
}
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.371",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz",
@@ -1346,6 +1517,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1395,6 +1573,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1464,6 +1662,13 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1474,6 +1679,16 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1510,6 +1725,23 @@
"node": ">=18"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1645,6 +1877,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1655,6 +1894,64 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || >=20.0.0"
}
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tinyspy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1760,6 +2057,112 @@
}
}
},
"node_modules/vite-node": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz",
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.7",
"es-module-lexer": "^1.5.4",
"pathe": "^1.1.2",
"vite": "^5.0.0"
},
"bin": {
"vite-node": "vite-node.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
"@vitest/pretty-format": "^2.1.9",
"@vitest/runner": "2.1.9",
"@vitest/snapshot": "2.1.9",
"@vitest/spy": "2.1.9",
"@vitest/utils": "2.1.9",
"chai": "^5.1.2",
"debug": "^4.3.7",
"expect-type": "^1.1.0",
"magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.1",
"tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.1.9",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.1.9",
"@vitest/ui": "2.1.9",
"happy-dom": "*",
"jsdom": "*"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+5 -3
View File
@@ -1,12 +1,13 @@
{
"name": "wiggleverse-ecomm-frontend",
"private": true,
"version": "0.1.0",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
@@ -17,6 +18,7 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vitest": "^2.1.8"
}
}
+8
View File
@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 648 B

+9
View File
@@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
+87 -6
View File
@@ -1,10 +1,91 @@
// SLICE-1 shell only — the Landing, Sign-in, Create-storefront, and Admin screens
// (SD-0001 §5) land in SLICE-2/3. This proves the build and dev server work.
// 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 (
<main>
<h1>ecomm</h1>
<p>Honest commerce. Your storefront is yours.</p>
</main>
<Admin
storefrontName={session!.storefront!.name}
email={email}
welcome={welcome}
onSignedOut={signedOut}
/>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { adminViewFor, hashFor, type AdminView } from "./adminRouting";
const VIEWS: AdminView[] = [
{ view: "home" }, { view: "products" }, { view: "import-upload" },
{ view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 },
];
describe("adminRouting", () => {
it("round-trips every view", () => {
for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v);
});
it("defaults junk to home/products", () => {
expect(adminViewFor("")).toEqual({ view: "home" });
expect(adminViewFor("#/nonsense")).toEqual({ view: "home" });
expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" });
});
});
+31
View File
@@ -0,0 +1,31 @@
// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a
// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing
// rule (routing.ts) still decides whether the admin renders at all.
export type AdminView =
| { view: "home" }
| { view: "products" }
| { view: "import-upload" }
| { view: "import-preview"; draftId: number }
| { view: "run-detail"; runId: number };
export function adminViewFor(hash: string): AdminView {
const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
if (parts[0] !== "products") return { view: "home" };
if (parts.length === 1) return { view: "products" };
if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" };
if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? ""))
return { view: "import-preview", draftId: Number(parts[3]) };
if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? ""))
return { view: "run-detail", runId: Number(parts[3]) };
return { view: "products" };
}
export function hashFor(v: AdminView): string {
switch (v.view) {
case "home": return "#/";
case "products": return "#/products";
case "import-upload": return "#/products/import";
case "import-preview": return `#/products/imports/drafts/${v.draftId}`;
case "run-detail": return `#/products/imports/runs/${v.runId}`;
}
}
+79
View File
@@ -0,0 +1,79 @@
// 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) };
}
+24
View File
@@ -0,0 +1,24 @@
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);
});
});
+17
View File
@@ -0,0 +1,17 @@
// 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
View File
@@ -1,3 +1,4 @@
import "./styles/index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { EXPORT_STATUSES, exportUrl } from "./productsApi";
describe("export url", () => {
it("lists the four status filters with 'all' first", () => {
expect(EXPORT_STATUSES.map((s) => s.value)).toEqual(["all", "active", "draft", "archived"]);
});
it("builds the endpoint url with the status query", () => {
expect(exportUrl("all")).toBe("/api/products/export?status=all");
expect(exportUrl("archived")).toBe("/api/products/export?status=archived");
});
});
+114
View File
@@ -0,0 +1,114 @@
// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same
// conventions as api.ts: same-origin, cookie session, §6.4 error envelope.
import { errorOf, type ApiError } from "./api";
export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; }
export interface Draft {
id: number; file_name: string; dialect: string;
summary: DiffSummary; unknown_columns: string[]; expires_at: string;
}
export type RecordKind = "add" | "update" | "unchanged" | "error";
export interface RowErrorDetail { line: number; column: string | null; message: string; }
export interface FieldChange { field: string; before: unknown; after: unknown; }
export interface VariantEntry {
options: (string | null)[]; kind?: string;
set?: Record<string, unknown>; changes?: FieldChange[];
}
export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; }
export interface DraftRecord {
handle: string; title: string; kind: RecordKind; variant_count: number;
detail: {
set?: Record<string, unknown>;
option_names?: (string | null)[];
changes?: FieldChange[];
variants?: VariantEntry[];
images?: ImageEntry[];
errors?: RowErrorDetail[];
};
}
export interface RunSummary {
id: number; file_name: string; dialect: string; created_at: string;
completed_at: string | null; status: string; by: string;
products_added: number; products_updated: number; rows_errored: number;
}
export interface RunDetail extends RunSummary {
errors: RowErrorDetail[];
image_progress: { done: number; total: number };
image_outcomes: unknown[];
}
export interface ProductsSummary {
product_count: number; image_problem_count: number; latest_run_id: number | null;
}
export type Result<T> = { ok: true; value: T } | { ok: false; error: ApiError; status: number };
// One label rule for CSV dialects, shared by Products history / preview / run detail.
export function dialectLabel(d: string): string {
return d === "canonical" ? "Canonical format" : d;
}
export type ExportStatus = "all" | "active" | "draft" | "archived";
// PUC-9 status filter, 'all' first (the default). Labels drive the export menu.
export const EXPORT_STATUSES: { value: ExportStatus; label: string }[] = [
{ value: "all", label: "All products" },
{ value: "active", label: "Active" },
{ value: "draft", label: "Draft" },
{ value: "archived", label: "Archived" },
];
// The export is a browser download (native save dialog + streaming), not a fetch
// wrapper — so the API module contributes a URL builder, not a request().
export function exportUrl(status: ExportStatus): string {
return `/api/products/export?status=${status}`;
}
async function request<T>(path: string, init?: RequestInit): Promise<Result<T>> {
const resp = await fetch(path, { credentials: "include", ...init });
if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status };
if (resp.status === 204) return { ok: true, value: undefined as T };
return { ok: true, value: (await resp.json()) as T };
}
export function getProductsSummary(): Promise<Result<ProductsSummary>> {
return request("/api/products/summary");
}
export function uploadImport(file: File): Promise<Result<Draft>> {
const body = new FormData();
body.append("file", file);
// No content-type header: the browser sets the multipart boundary.
return request("/api/products/imports", { method: "POST", body });
}
export function getDraft(id: number): Promise<Result<Draft>> {
return request(`/api/products/imports/drafts/${id}`);
}
export async function getDraftRecords(
id: number, kind?: RecordKind, limit = 100, offset = 0,
): Promise<Result<DraftRecord[]>> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (kind) params.set("kind", kind);
const resp = await request<{ records: DraftRecord[] }>(
`/api/products/imports/drafts/${id}/records?${params}`,
);
return resp.ok ? { ok: true, value: resp.value.records } : resp;
}
export function confirmDraft(id: number): Promise<Result<{ run_id: number }>> {
return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" });
}
export function cancelDraft(id: number): Promise<Result<void>> {
return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" });
}
export async function listRuns(): Promise<Result<RunSummary[]>> {
const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs");
return resp.ok ? { ok: true, value: resp.value.runs } : resp;
}
export function getRun(id: number): Promise<Result<RunDetail>> {
return request(`/api/products/imports/runs/${id}`);
}
+20
View File
@@ -0,0 +1,20 @@
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");
});
});
+16
View File
@@ -0,0 +1,16 @@
// 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";
}
+88
View File
@@ -0,0 +1,88 @@
// 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>
);
}
+92
View File
@@ -0,0 +1,92 @@
// 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>
);
}
+61
View File
@@ -0,0 +1,61 @@
// 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>
);
}
+198
View File
@@ -0,0 +1,198 @@
// 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>
);
}
@@ -0,0 +1,393 @@
// Import preview (SD-0002 §5.4) — the consent gate. Summary tiles filter a
// drill-in diff list; the sticky footer carries confirm (PUC-3) / cancel (PUC-3a).
// Diff glyphs pair with color, never color alone (§6.6).
import { useEffect, useRef, useState } from "react";
import {
cancelDraft,
confirmDraft,
dialectLabel,
getDraft,
getDraftRecords,
type Draft,
type DraftRecord,
type FieldChange,
type RecordKind,
type VariantEntry,
} from "../../productsApi";
import { Banner } from "../../ui/kit";
const PAGE = 100;
function fmt(v: unknown): string {
if (v === null || v === undefined) return "—";
if (Array.isArray(v)) return v.length ? v.join(", ") : "—";
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return String(v);
}
function KindChip({ kind }: { kind: RecordKind }) {
return <span className={`kindchip kindchip--${kind}`}>{kind}</span>;
}
function SetLine({ field, value }: { field: string; value: unknown }) {
return (
<div className="diffchange">
<span className="diffchange__glyph--add">+ </span>
{field}: {fmt(value)}
</div>
);
}
function ChangeRow({ change }: { change: FieldChange }) {
return (
<div className="diffchange">
{change.field}: <span className="diffchange__glyph--del"> {fmt(change.before)}</span> {" "}
<span className="diffchange__glyph--add">+ {fmt(change.after)}</span>
</div>
);
}
function variantLabel(v: VariantEntry): string {
const opts = v.options.filter((o): o is string => o != null);
return opts.length ? `Variant ${opts.join(" / ")}` : "Variant";
}
function RecordDetail({ record }: { record: DraftRecord }) {
const d = record.detail;
if (record.kind === "error") {
return (
<div>
{(d.errors ?? []).map((e, i) => (
<div className="diffchange" key={i}>
line {e.line}: {e.column != null && `'${e.column}' — `}
{e.message}
</div>
))}
</div>
);
}
return (
<div>
{Object.entries(d.set ?? {}).map(([field, value]) => (
<SetLine key={field} field={field} value={value} />
))}
{(d.changes ?? []).map((c, i) => (
<ChangeRow key={i} change={c} />
))}
{(d.variants ?? []).map((v, i) => (
<div key={i}>
<div className="diffchange diffchange--head">
{variantLabel(v)}
{v.kind ? ` (${v.kind})` : ""}
</div>
{Object.entries(v.set ?? {}).map(([field, value]) => (
<SetLine key={field} field={field} value={value} />
))}
{(v.changes ?? []).map((c, j) => (
<ChangeRow key={j} change={c} />
))}
</div>
))}
{(d.images ?? []).map((img, i) =>
img.kind && img.kind !== "add" ? (
<div key={i}>
<div className="diffchange diffchange--head">
image: {img.src} ({img.kind})
</div>
{(img.changes ?? []).map((c, j) => (
<ChangeRow key={j} change={c} />
))}
</div>
) : (
<div className="diffchange" key={i}>
<span className="diffchange__glyph--add">+ </span>image: {img.src}
</div>
),
)}
</div>
);
}
function ErrorTable({ records }: { records: DraftRecord[] }) {
const rows = records.flatMap((r) => r.detail.errors ?? []);
if (rows.length === 0) return null;
return (
<table className="errortable">
<thead>
<tr>
<th>Line</th>
<th>Column</th>
<th>Problem</th>
</tr>
</thead>
<tbody>
{rows.map((e, i) => (
<tr key={i}>
<td>{e.line}</td>
<td>{e.column ?? "—"}</td>
<td>{e.message}</td>
</tr>
))}
</tbody>
</table>
);
}
export default function ImportPreview({ draftId }: { draftId: number }) {
const [draft, setDraft] = useState<Draft | null>(null);
const [loadFail, setLoadFail] = useState<"gone" | "expired" | "failed" | null>(null);
const [filter, setFilter] = useState<RecordKind | null>(null);
const [records, setRecords] = useState<DraftRecord[] | null>(null);
const [recordsError, setRecordsError] = useState<"load" | "more" | null>(null);
const [hasMore, setHasMore] = useState(false);
const [moreBusy, setMoreBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [stale, setStale] = useState(false);
const [nothingNote, setNothingNote] = useState(false);
const [confirmError, setConfirmError] = useState<string | null>(null);
// Generation counter for the records list: bumped on every page-0 (re)load, so a
// page-0 or show-more response that resolves after a tile/filter (or draft) switch
// is recognized as stale and dropped instead of clobbering/appending to the new list.
const recordsGen = useRef(0);
async function loadDraft() {
setLoadFail(null);
const resp = await getDraft(draftId);
if (!resp.ok) {
setLoadFail(resp.status === 404 ? "gone" : resp.status === 410 ? "expired" : "failed");
return;
}
setDraft(resp.value);
}
useEffect(() => {
// A new draft means a fresh consent gate — reset everything the old one set.
setFilter(null);
setStale(false);
setConfirmError(null);
setNothingNote(false);
setRecords(null);
setRecordsError(null);
setHasMore(false);
void loadDraft();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draftId]);
function loadRecords(kind: RecordKind | null) {
recordsGen.current += 1;
const gen = recordsGen.current;
setRecords(null);
setRecordsError(null);
setHasMore(false);
void getDraftRecords(draftId, kind ?? undefined, PAGE, 0).then((resp) => {
if (gen !== recordsGen.current) return;
if (!resp.ok) {
setRecordsError("load");
return;
}
setRecords(resp.value);
setHasMore(resp.value.length === PAGE);
});
}
useEffect(() => {
if (!draft) return;
loadRecords(filter);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft, draftId, filter]);
async function showMore() {
if (!records) return;
const gen = recordsGen.current;
setMoreBusy(true);
setRecordsError(null);
const resp = await getDraftRecords(draftId, filter ?? undefined, PAGE, records.length);
setMoreBusy(false);
// Filter/draft switched while this page was in flight — drop the stale page.
if (gen !== recordsGen.current) return;
if (!resp.ok) {
setRecordsError("more");
return;
}
setRecords((prev) => [...(prev ?? []), ...resp.value]);
setHasMore(resp.value.length === PAGE);
}
async function onConfirm() {
setConfirming(true);
setConfirmError(null);
const resp = await confirmDraft(draftId);
if (resp.ok) {
window.location.hash = `#/products/imports/runs/${resp.value.run_id}`;
return;
}
setConfirming(false);
if ((resp.status === 409 && resp.error.code === "preview_stale") || resp.status === 410) {
setStale(true);
} else if (resp.status === 409 && resp.error.code === "nothing_to_apply") {
setNothingNote(true);
} else {
setConfirmError(resp.error.message);
}
}
async function onCancel() {
setCancelling(true);
// PUC-3a — cancel even on draft-gone (404) still navigates home.
await cancelDraft(draftId);
window.location.hash = "#/products";
}
if (loadFail === "gone") {
return (
<Banner tone="attn" title="This preview is gone">
<a href="#/products">Back to Products</a>
</Banner>
);
}
if (loadFail === "expired") {
return (
<Banner tone="attn" title="This preview expired — upload the file again">
<a href="#/products/import">Upload the file again</a>
</Banner>
);
}
if (loadFail === "failed") {
return (
<Banner tone="attn" title="Couldn't load this preview">
Something went wrong on our side.{" "}
<button type="button" className="linklike" onClick={() => void loadDraft()}>
Retry
</button>
</Banner>
);
}
if (!draft) {
return (
<p className="note" role="status">
Loading
</p>
);
}
const { summary } = draft;
const tiles: { kind: RecordKind; num: number; label: string }[] = [
{ kind: "add", num: summary.adds, label: "to add" },
{ kind: "update", num: summary.updates, label: "to update" },
{ kind: "unchanged", num: summary.unchanged, label: "unchanged" },
{ kind: "error", num: summary.errors, label: "errors" },
];
const toApply = summary.adds + summary.updates;
return (
<div className="products">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>Import preview {draft.file_name}</h1>
<p className="note">{dialectLabel(draft.dialect)}</p>
{draft.unknown_columns.length > 0 && (
<Banner tone="info" title="Columns not imported">
{draft.unknown_columns.length > 8 ? (
<details>
<summary>{draft.unknown_columns.length} columns not imported</summary>
{draft.unknown_columns.join(", ")}
</details>
) : (
draft.unknown_columns.join(", ")
)}
</Banner>
)}
<div className="tiles">
{tiles.map((t) => (
<button
key={t.kind}
type="button"
className={`tile tile--${t.kind}${filter === t.kind ? " tile--active" : ""}`}
aria-pressed={filter === t.kind}
onClick={() => setFilter(filter === t.kind ? null : t.kind)}
>
<span className="tile__num">{t.num.toLocaleString()}</span>
<span className="tile__label">{t.label}</span>
</button>
))}
</div>
{filter === "error" && records && <ErrorTable records={records} />}
{recordsError === "load" ? (
<p className="note note--attn" role="alert">
Couldn't load these records.{" "}
<button type="button" className="linklike" onClick={() => loadRecords(filter)}>
Retry
</button>
</p>
) : records === null ? (
<p className="note" role="status">
Loading…
</p>
) : records.length === 0 ? (
<p className="note">Nothing to show here.</p>
) : (
<div className="difflist">
{records.map((r, i) => (
<details className="difflist__item" key={`${r.handle}-${i}`}>
<summary>
<span className="difflist__handle">{r.handle}</span> · {r.title} ·{" "}
<KindChip kind={r.kind} /> · {r.variant_count}{" "}
{r.variant_count === 1 ? "variant" : "variants"}
</summary>
<RecordDetail record={r} />
</details>
))}
</div>
)}
{hasMore && (
<p>
<button type="button" className="btn-secondary" disabled={moreBusy} onClick={() => void showMore()}>
{moreBusy ? "Loading…" : "Show more"}
</button>
{recordsError === "more" && (
<span className="note note--attn" role="alert">
Couldn't load more records.{" "}
<button type="button" className="linklike" onClick={() => void showMore()}>
Retry
</button>
</span>
)}
</p>
)}
<div className="sticky-footer" aria-live="polite">
{stale ? (
<Banner tone="attn" title="Your catalog changed since this preview — upload the file again">
<a href="#/products/import">Upload the file again</a>
</Banner>
) : (
<>
<button
type="button"
className="btn-primary"
disabled={toApply === 0 || confirming || cancelling}
onClick={() => void onConfirm()}
>
{confirming ? "Importing…" : `Import ${toApply.toLocaleString()} products`}
</button>
<button
type="button"
className="btn-secondary"
disabled={confirming || cancelling}
onClick={() => void onCancel()}
>
Cancel
</button>
{(toApply === 0 || nothingNote) && (
<span className="note">Nothing to change your catalog already matches this file</span>
)}
{confirmError && (
<span className="note note--attn" role="alert">
{confirmError}
</span>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,60 @@
// Import — upload (SD-0002 §5.3). Selecting a file starts upload + validation
// immediately (PUC-2); file-level rejections (PUC-5a) render in place with the
// picker live for retry. No notifications — errors render here.
import { useRef, useState } from "react";
import { uploadImport } from "../../productsApi";
import { Banner } from "../../ui/kit";
export default function ImportUpload() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function onPick(files: FileList | null) {
const file = files?.[0];
if (!file) return;
setBusy(true);
setError(null);
const resp = await uploadImport(file);
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
if (!resp.ok) {
setError(resp.error.message);
return;
}
window.location.hash = `#/products/imports/drafts/${resp.value.id}`;
}
return (
<div className="products products--narrow">
<p className="note">
<a href="#/products"> Products</a>
</p>
<h1>Import products</h1>
{error && (
<Banner tone="attn" title="That file can't be imported">
{error}
</Banner>
)}
<label className={`dropzone${busy ? " dropzone--busy" : ""}`}>
<input
ref={inputRef}
type="file"
accept=".csv,text/csv"
disabled={busy}
onChange={(e) => void onPick(e.target.files)}
/>
<span className="dropzone__title" aria-live="polite">
{busy ? "Validating…" : "Choose a CSV file"}
</span>
<span className="note">CSV, up to 5,000 rows</span>
</label>
<p className="note">
Works with the canonical format.{" "}
<a href="/api/products/sample.csv" download>
Download sample CSV
</a>
</p>
</div>
);
}
@@ -0,0 +1,154 @@
// Products page (SD-0002 §5.2) — the catalog's home: where imports start, exports
// download, and history lives. SLICE-6 ships the export menu; the browsable list is #14's.
import { useEffect, useState } from "react";
import {
dialectLabel,
EXPORT_STATUSES,
exportUrl,
getProductsSummary,
listRuns,
type ProductsSummary,
type RunSummary,
} from "../../productsApi";
import { Banner } from "../../ui/kit";
import { isExportEnabled } from "./exportMenu";
const STATUS_LABELS: Record<string, string> = {
applying: "Importing…",
fetching_images: "Fetching images…",
complete: "Complete",
complete_with_problems: "Complete with problems",
};
export default function ProductsPage() {
const [summary, setSummary] = useState<ProductsSummary | null>(null);
const [runs, setRuns] = useState<RunSummary[] | null>(null);
const [failed, setFailed] = useState(false);
async function load() {
setFailed(false);
const [s, r] = await Promise.all([getProductsSummary(), listRuns()]);
if (!s.ok || !r.ok) {
setFailed(true);
return;
}
setSummary(s.value);
setRuns(r.value);
}
useEffect(() => {
void load();
}, []);
if (failed) {
return (
<Banner tone="attn" title="Couldn't load your products">
Something went wrong on our side.{" "}
<button type="button" className="linklike" onClick={() => void load()}>
Retry
</button>
</Banner>
);
}
if (!summary || !runs) {
return (
<p className="note" role="status">
Loading
</p>
);
}
const empty = !isExportEnabled(summary.product_count);
return (
<div className="products">
<header className="products__header">
<h1>
Products
{!empty && <span className="products__count"> · {summary.product_count.toLocaleString()}</span>}
</h1>
<div className="products__actions">
{empty ? (
<div className="products__export">
<button type="button" className="btn-secondary" disabled>
Export
</button>
<span className="note">Export arrives when you have products</span>
</div>
) : (
<details className="products__export menu">
<summary className="btn-secondary" role="button">
Export
</summary>
<ul className="menu__list">
{EXPORT_STATUSES.map((s) => (
<li key={s.value}>
{/* A real download: the browser navigates to the streamed
endpoint and saves the attachment (PUC-9). */}
<a className="menu__item" href={exportUrl(s.value)} download>
{s.label}
</a>
</li>
))}
</ul>
</details>
)}
<a className="btn-primary" href="#/products/import">
Import products
</a>
</div>
</header>
{empty ? (
<div className="empty">
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
<a className="btn-primary" href="#/products/import">
Import products
</a>
<p className="note">
<a href="/api/products/sample.csv" download>
Download sample CSV
</a>
</p>
</div>
) : (
<p className="note">Your catalog is loaded. The browsable product list arrives with an upcoming release.</p>
)}
<section className="products__history">
<h2>Import history</h2>
{runs.length === 0 ? (
<p className="note">No imports yet.</p>
) : (
<table className="datatable">
<thead>
<tr>
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr
key={r.id}
className="datatable__rowlink"
onClick={() => {
window.location.hash = `#/products/imports/runs/${r.id}`;
}}
>
<td>{new Date(r.created_at).toLocaleString()}</td>
<td>
{/* Anchor = the keyboard/SR path (§6.6); the row onClick stays as a
mouse convenience. Both set the same hash, so the double fire on
an anchor click is idempotent. */}
<a href={`#/products/imports/runs/${r.id}`}>{r.file_name}</a>
</td>
<td>{dialectLabel(r.dialect)}</td>
<td>{r.products_added}</td>
<td>{r.products_updated}</td>
<td>{r.rows_errored}</td>
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More