Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c929282e07 | |||
| 757412ef2a | |||
| 0775537d4c | |||
| d81215be1d | |||
| 7f51f5242f | |||
| 5d5341b29b | |||
| 23267c4d4c | |||
| 13e74f4c61 | |||
| 984dc98dee | |||
| 906bc87c96 | |||
| da5177c950 | |||
| 0fc29a34dd | |||
| f27a24353d | |||
| 385df8d728 | |||
| af72299a57 | |||
| 6f22c8b146 | |||
| 767011fbae | |||
| 8077f2e07b | |||
| 0a85c4fef8 | |||
| 6f213e1f02 | |||
| 155f9bd147 | |||
| 1c2a6af986 | |||
| 5a97b9dc59 | |||
| fce0b5eaed | |||
| b40e4d30b5 | |||
| 7652e92cbe | |||
| 627e257d4c | |||
| 011f4d5dc1 | |||
| 2606fbf826 | |||
| ad738adbc0 | |||
| a58f42cf86 | |||
| d18a84e135 | |||
| 87a56a66c4 | |||
| 4141285b89 | |||
| 188494272a | |||
| 26f84cb916 | |||
| b5dac8886f | |||
| 4dacc2dafd | |||
| a8538d3ecc | |||
| 0c7865e9e1 | |||
| fcbf1393f5 | |||
| 138126ab17 | |||
| 667a462e0b | |||
| 9bc6e4dbd2 | |||
| 19ee695c20 | |||
| f64c3fddf9 | |||
| c39bbd4728 | |||
| 0ee948b34d | |||
| 1267d4f29d | |||
| 6b405a0f2d | |||
| 118e925580 | |||
| 11acb3a5b1 | |||
| efc9c8edb9 | |||
| f7b76d5370 | |||
| 4bb9763633 | |||
| a456d51f17 | |||
| ac3c4ffe36 | |||
| 8a2ec24d86 | |||
| acff7ac588 | |||
| 22d738fc74 | |||
| 1b8c60fcb5 | |||
| 9f4295b77e | |||
| a1c5544694 | |||
| 0747181157 | |||
| 877be249c7 | |||
| 25ac540171 | |||
| 10d22938f1 | |||
| ddc949615e | |||
| 0b302ea1e1 | |||
| ef1498ba08 | |||
| cc9d9dda5f | |||
| 306b5c1e5d | |||
| e11fb032b1 | |||
| ba8b493a31 | |||
| 1b40bde4a0 | |||
| 725877c5c6 | |||
| bf62f413d7 | |||
| cfc28d7002 | |||
| b3ffb2d4b6 | |||
| cfd2b4ecc7 | |||
| d2eceac272 | |||
| 95680c9960 | |||
| 2d15f1a2cb | |||
| 446c13211a | |||
| 7a6b396f65 | |||
| 9373177ed3 | |||
| f184802f1f | |||
| 4d1c957f42 | |||
| cd951cca2c | |||
| 886da73eb6 | |||
| 6744a6ae0d | |||
| 36046304da | |||
| d7b9f81e53 | |||
| ef385340e8 | |||
| 9462423642 | |||
| ab4317e105 | |||
| 346e7f2e22 | |||
| 4acab40653 | |||
| d125317c00 | |||
| 0078b0c6da | |||
| 23afcf95ce |
@@ -0,0 +1,57 @@
|
|||||||
|
# Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so
|
||||||
|
# the local and server gates can never drift: import-linter (main > domains >
|
||||||
|
# platform), then backend pytest against a Postgres service, then the frontend
|
||||||
|
# typecheck + build. Requires a registered Gitea Actions runner advertising the
|
||||||
|
# `ubuntu-latest` label.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ecomm
|
||||||
|
POSTGRES_PASSWORD: ecomm
|
||||||
|
POSTGRES_DB: ecomm
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U ecomm"
|
||||||
|
--health-interval 2s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install backend deps
|
||||||
|
run: |
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/bin/python -m pip install --upgrade pip
|
||||||
|
.venv/bin/python -m pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: |
|
||||||
|
cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Run the pre-merge gate
|
||||||
|
env:
|
||||||
|
PYTHON: ${{ github.workspace }}/.venv/bin/python
|
||||||
|
ECOMM_TEST_ADMIN_URL: postgresql://ecomm:ecomm@localhost:5432/postgres
|
||||||
|
run: scripts/check.sh
|
||||||
@@ -6,3 +6,6 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
*.local
|
*.local
|
||||||
|
.pytest_cache/
|
||||||
|
frontend/dist/
|
||||||
|
backend/var/
|
||||||
|
|||||||
@@ -20,5 +20,14 @@ This app (One Name `ecomm`, see [`app.json`](./app.json)) is composed of:
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
No application code yet. The architecture and first release are to be designed in
|
SLICE-3 (storefront) of SD-0001 is in place on top of SLICE-1/2: the `storefronts`
|
||||||
a brainstorming/planning session before any code lands.
|
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).
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Enforces SD-0001 §6.2: the four-layer contract main > domains > platform, with
|
||||||
|
# imports flowing only downward. The GraphQL `api` layer is deferred (§2), so the
|
||||||
|
# MVP contract has three layers; `app.api` is added back between main and domains
|
||||||
|
# when GraphQL arrives. Run from backend/: .venv/bin/lint-imports
|
||||||
|
[importlinter]
|
||||||
|
root_package = app
|
||||||
|
|
||||||
|
[importlinter:contract:layers]
|
||||||
|
name = App layering (main > domains > platform)
|
||||||
|
type = layers
|
||||||
|
layers =
|
||||||
|
app.main
|
||||||
|
app.domains
|
||||||
|
app.platform
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""domains layer — bounded contexts (accounts, storefronts).
|
||||||
|
|
||||||
|
Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3.
|
||||||
|
The package exists now so the layer contract (.importlinter) has a target and later
|
||||||
|
slices add to a stable seam. Domains import only from app.platform, never upward.
|
||||||
|
"""
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""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 .imagefetch import recover_incomplete_runs, run_image_phase
|
||||||
|
from .errors import (
|
||||||
|
DraftExpired,
|
||||||
|
DraftNotFound,
|
||||||
|
EmptyCatalog,
|
||||||
|
FileRejected,
|
||||||
|
NothingToApply,
|
||||||
|
PreviewStale,
|
||||||
|
ProductsError,
|
||||||
|
RunNotFound,
|
||||||
|
)
|
||||||
|
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
|
||||||
|
from .repo import image_for_serving
|
||||||
|
from .service import (
|
||||||
|
confirm_draft,
|
||||||
|
discard_draft,
|
||||||
|
export_catalog,
|
||||||
|
get_draft,
|
||||||
|
get_draft_records,
|
||||||
|
get_run,
|
||||||
|
import_validate,
|
||||||
|
list_runs,
|
||||||
|
summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
"run_image_phase", "recover_incomplete_runs", "image_for_serving",
|
||||||
|
]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""CSV codec — bytes → ParsedFile (SD-0002 §6.5.1). File-level gates only (PUC-5a);
|
||||||
|
row semantics live in validate.py. Dialect detection is the INV-17 seam (SLICE-8
|
||||||
|
adds Shopify)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from .errors import FileRejected
|
||||||
|
from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row
|
||||||
|
|
||||||
|
_REQUIRED_HEADER_COLUMNS = ("Handle", "Title")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_dialect(header: list[str]) -> str:
|
||||||
|
"""The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here."""
|
||||||
|
return "canonical"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csv(data: bytes) -> ParsedFile:
|
||||||
|
if len(data) > MAX_FILE_BYTES:
|
||||||
|
raise FileRejected("file_too_large", "This file is larger than 10 MB.")
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||||
|
reader = csv.reader(io.StringIO(text))
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
raw_header = next(reader)
|
||||||
|
except StopIteration:
|
||||||
|
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||||
|
header = [h.strip() for h in raw_header]
|
||||||
|
for col in _REQUIRED_HEADER_COLUMNS:
|
||||||
|
if col not in header:
|
||||||
|
raise FileRejected(
|
||||||
|
"missing_required_column",
|
||||||
|
f"This file is missing the required column '{col}'.",
|
||||||
|
)
|
||||||
|
# First occurrence of a duplicated column wins.
|
||||||
|
col_index: dict[str, int] = {}
|
||||||
|
for i, name in enumerate(header):
|
||||||
|
if name and name not in col_index:
|
||||||
|
col_index[name] = i
|
||||||
|
known_present = [c for c in col_index if c in KNOWN_COLUMNS]
|
||||||
|
unknown = [c for c in col_index if c not in KNOWN_COLUMNS]
|
||||||
|
rows: list[Row] = []
|
||||||
|
for raw in reader:
|
||||||
|
if not any(cell.strip() for cell in raw):
|
||||||
|
continue
|
||||||
|
if len(rows) >= MAX_DATA_ROWS:
|
||||||
|
raise FileRejected(
|
||||||
|
"too_many_rows",
|
||||||
|
f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.",
|
||||||
|
)
|
||||||
|
cells = {
|
||||||
|
c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "")
|
||||||
|
for c in known_present
|
||||||
|
}
|
||||||
|
rows.append(Row(line_number=reader.line_num, cells=cells))
|
||||||
|
except csv.Error:
|
||||||
|
raise FileRejected("not_csv", "This file isn't readable as CSV.") from None
|
||||||
|
return ParsedFile(
|
||||||
|
dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows
|
||||||
|
)
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
"""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, replace
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from . import hosted
|
||||||
|
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant, RowError
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
status: str = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
_resolve_hosted_images(product, current)
|
||||||
|
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 _resolve_hosted_images(product: CanonicalProduct, current: CatalogProduct | None) -> None:
|
||||||
|
"""Recognize re-imported hosted image URLs (INV-12). A hosted URL is resolved
|
||||||
|
to this product's existing image with that id — re-keyed onto that image's
|
||||||
|
source_url so the by_src match treats it as the existing image (never an add,
|
||||||
|
never a fetch). A hosted URL whose id is not one of this product's images
|
||||||
|
(cross-storefront, deleted, or a brand-new product) is a row error."""
|
||||||
|
current_by_id = {i.id: i for i in current.images} if current else {}
|
||||||
|
resolved: list = []
|
||||||
|
for image in product.images:
|
||||||
|
hosted_id = hosted.parse_image_id(image.source_url)
|
||||||
|
if hosted_id is None:
|
||||||
|
resolved.append(image)
|
||||||
|
continue
|
||||||
|
match = current_by_id.get(hosted_id)
|
||||||
|
if match is None:
|
||||||
|
product.errors.append(
|
||||||
|
RowError(image.line_number, "Image Src",
|
||||||
|
f"image '{image.source_url}' refers to an unknown hosted id")
|
||||||
|
)
|
||||||
|
resolved.append(image)
|
||||||
|
continue
|
||||||
|
resolved.append(replace(image, source_url=match.source_url))
|
||||||
|
product.images = resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _add_plan(product: CanonicalProduct) -> ProductPlan:
|
||||||
|
return ProductPlan(
|
||||||
|
kind="add",
|
||||||
|
canonical=product,
|
||||||
|
catalog=None,
|
||||||
|
variant_plans=[
|
||||||
|
VariantPlan(kind="add", canonical=v, catalog_id=None, file_order=order)
|
||||||
|
for order, v in enumerate(product.variants, start=1)
|
||||||
|
],
|
||||||
|
image_plans=[
|
||||||
|
ImagePlan(kind="add", source_url=i.source_url, position=i.position,
|
||||||
|
alt_text=i.alt_text, image_id=None)
|
||||||
|
for i in product.images
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_detail(plan: ProductPlan) -> dict:
|
||||||
|
# On add, absent fields fall back to their defaults for display where one
|
||||||
|
# exists — the detail shows what will actually be set.
|
||||||
|
set_fields = resolved_product_fields(plan.canonical)
|
||||||
|
for field_name, default in CLEAR_DEFAULTS.items():
|
||||||
|
set_fields.setdefault(field_name, default)
|
||||||
|
return {
|
||||||
|
"set": {f: _json_safe(v) for f, v in set_fields.items()},
|
||||||
|
"option_names": list(plan.canonical.option_names),
|
||||||
|
"variants": [
|
||||||
|
{"options": list(vp.canonical.options),
|
||||||
|
"set": _resolved_variant_fields(vp.canonical, vp.file_order)}
|
||||||
|
for vp in plan.variant_plans
|
||||||
|
],
|
||||||
|
"images": [
|
||||||
|
{"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
|
||||||
|
for ip in plan.image_plans
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]:
|
||||||
|
"""One walk, two outputs: the resolved-value plan entries and the json-safe
|
||||||
|
record detail entries are appended side by side, so they can never diverge."""
|
||||||
|
product_changes: dict[str, object] = {}
|
||||||
|
changes: list[dict] = []
|
||||||
|
# Title is always file-present (required header column); "" means the block
|
||||||
|
# already carries an error and never reaches here.
|
||||||
|
if product.title and product.title != current.title:
|
||||||
|
product_changes["title"] = product.title
|
||||||
|
changes.append(_change("title", current.title, product.title))
|
||||||
|
for field_name, resolved in resolved_product_fields(product).items():
|
||||||
|
before = current.fields.get(field_name)
|
||||||
|
if resolved != before:
|
||||||
|
product_changes[field_name] = resolved
|
||||||
|
changes.append(_change(field_name, before, resolved))
|
||||||
|
for slot in (1, 2, 3):
|
||||||
|
if f"option{slot}_name" not in product.fields:
|
||||||
|
continue
|
||||||
|
before, after = current.option_names[slot - 1], product.option_names[slot - 1]
|
||||||
|
if after != before:
|
||||||
|
product_changes[f"option{slot}_name"] = after
|
||||||
|
changes.append(_change(f"option{slot}_name", before, after))
|
||||||
|
|
||||||
|
variant_plans: list[VariantPlan] = []
|
||||||
|
variant_entries: list[dict] = []
|
||||||
|
by_options = {v.options: v for v in current.variants}
|
||||||
|
for file_order, variant in enumerate(product.variants, start=1):
|
||||||
|
match = by_options.get(variant.options)
|
||||||
|
if match is None:
|
||||||
|
variant_plans.append(
|
||||||
|
VariantPlan(kind="add", canonical=variant, catalog_id=None, file_order=file_order)
|
||||||
|
)
|
||||||
|
variant_entries.append(
|
||||||
|
{"options": list(variant.options), "kind": "add",
|
||||||
|
"set": _resolved_variant_fields(variant, file_order)}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
variant_changes: dict[str, object] = {}
|
||||||
|
variant_change_entries: list[dict] = []
|
||||||
|
for f, resolved in resolved_variant_fields(variant, file_order).items():
|
||||||
|
# position lives on the catalog variant as an attribute, not in
|
||||||
|
# fields{} — compare it explicitly (as images do for theirs).
|
||||||
|
before = match.position if f == "position" else match.fields.get(f)
|
||||||
|
if resolved != before:
|
||||||
|
variant_changes[f] = resolved
|
||||||
|
variant_change_entries.append(_change(f, before, resolved))
|
||||||
|
if variant_changes:
|
||||||
|
variant_plans.append(
|
||||||
|
VariantPlan(kind="update", canonical=variant, catalog_id=match.id,
|
||||||
|
file_order=file_order, changes=variant_changes)
|
||||||
|
)
|
||||||
|
variant_entries.append(
|
||||||
|
{"options": list(variant.options), "kind": "update", "changes": variant_change_entries}
|
||||||
|
)
|
||||||
|
|
||||||
|
image_plans: list[ImagePlan] = []
|
||||||
|
image_entries: list[dict] = []
|
||||||
|
by_src = {i.source_url: i for i in current.images}
|
||||||
|
for image in product.images:
|
||||||
|
match = by_src.get(image.source_url)
|
||||||
|
if match is None:
|
||||||
|
image_plans.append(
|
||||||
|
ImagePlan(kind="add", source_url=image.source_url, position=image.position,
|
||||||
|
alt_text=image.alt_text, image_id=None)
|
||||||
|
)
|
||||||
|
image_entries.append(
|
||||||
|
{"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
image_changes: dict[str, object] = {}
|
||||||
|
image_change_entries: list[dict] = []
|
||||||
|
for f, before, after in (
|
||||||
|
("position", match.position, image.position),
|
||||||
|
("alt_text", match.alt_text, image.alt_text),
|
||||||
|
):
|
||||||
|
if after != before:
|
||||||
|
image_changes[f] = after
|
||||||
|
image_change_entries.append(_change(f, before, after))
|
||||||
|
if image_changes:
|
||||||
|
image_plans.append(
|
||||||
|
ImagePlan(kind="update", source_url=image.source_url, position=image.position,
|
||||||
|
alt_text=image.alt_text, image_id=match.id, changes=image_changes)
|
||||||
|
)
|
||||||
|
image_entries.append(
|
||||||
|
{"src": image.source_url, "kind": "update", "changes": image_change_entries}
|
||||||
|
)
|
||||||
|
|
||||||
|
detail: dict = {}
|
||||||
|
if changes:
|
||||||
|
detail["changes"] = changes
|
||||||
|
if variant_entries:
|
||||||
|
detail["variants"] = variant_entries
|
||||||
|
if image_entries:
|
||||||
|
detail["images"] = image_entries
|
||||||
|
kind = "update" if detail else "unchanged"
|
||||||
|
return (
|
||||||
|
ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes,
|
||||||
|
variant_plans=variant_plans, image_plans=image_plans),
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_fields(fields: dict[str, object]) -> dict[str, object]:
|
||||||
|
"""File-present fields with None (an explicit clear) resolved to the default."""
|
||||||
|
return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_product_fields(product: CanonicalProduct) -> dict[str, object]:
|
||||||
|
"""Resolved product-level fields, minus the option-name presence markers."""
|
||||||
|
return {
|
||||||
|
f: v for f, v in resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
|
||||||
|
"""File-present variant fields with clears resolved. A cleared position has
|
||||||
|
no CLEAR_DEFAULTS entry — it resets to the variant's 1-based file order
|
||||||
|
within its product ("defaults to file order"), never to NULL."""
|
||||||
|
resolved = resolved_fields(variant.fields)
|
||||||
|
if "position" in resolved and resolved["position"] is None:
|
||||||
|
resolved["position"] = file_order
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_variant_fields(variant: CanonicalVariant, file_order: int) -> dict[str, object]:
|
||||||
|
return {f: _json_safe(v) for f, v in resolved_variant_fields(variant, file_order).items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _change(field_name: str, before: object, after: object) -> dict:
|
||||||
|
return {"field": field_name, "before": _json_safe(before), "after": _json_safe(after)}
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(value: object) -> object:
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, (tuple, list)):
|
||||||
|
return [_json_safe(v) for v in value]
|
||||||
|
return value
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""products domain errors (SD-0002 §6.4 error envelope codes)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class ProductsError(Exception):
|
||||||
|
"""Base for products-domain errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileRejected(ProductsError):
|
||||||
|
"""PUC-5a: the whole file is unusable; no draft is created. `code` is the §6.4
|
||||||
|
error code (not_csv | missing_required_column | unknown_dialect | too_many_rows |
|
||||||
|
file_too_large)."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class DraftNotFound(ProductsError):
|
||||||
|
"""No such draft for this storefront (or already discarded)."""
|
||||||
|
|
||||||
|
|
||||||
|
class DraftExpired(ProductsError):
|
||||||
|
"""The draft's validity window passed (§6.3 ~1 h)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewStale(ProductsError):
|
||||||
|
"""INV-11: the catalog changed since validation — the previewed diff no longer holds."""
|
||||||
|
|
||||||
|
|
||||||
|
class NothingToApply(ProductsError):
|
||||||
|
"""PUC-10: no adds and no updates — confirming would be a no-op."""
|
||||||
|
|
||||||
|
|
||||||
|
class RunNotFound(ProductsError):
|
||||||
|
"""No such import run for this storefront."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyCatalog(ProductsError):
|
||||||
|
"""PUC-9: nothing to export (no products, or none matching the status filter)."""
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Hosted-image URL helpers (SD-0002 §6.3.1, INV-12).
|
||||||
|
|
||||||
|
Build the canonical hosted Image Src for a fetched image, and recognize one on
|
||||||
|
re-import. DB-free and host-agnostic: recognition is a path-parse, so an export
|
||||||
|
round-trips across localhost / PPE / the #23 rebrand. The diff resolves a parsed
|
||||||
|
id against the storefront-scoped catalog snapshot.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
RENDITIONS = ("original", "thumb", "card", "detail")
|
||||||
|
EXPORT_RENDITION = "detail"
|
||||||
|
|
||||||
|
_PATH = re.compile(r"^/api/products/images/(\d+)/(original|thumb|card|detail)$")
|
||||||
|
|
||||||
|
|
||||||
|
def image_url(base_url: str, image_id: int, rendition: str = EXPORT_RENDITION) -> str:
|
||||||
|
"""Absolute when base_url is set, else a root-relative path."""
|
||||||
|
path = f"/api/products/images/{image_id}/{rendition}"
|
||||||
|
return f"{base_url.rstrip('/')}{path}" if base_url else path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_image_id(url: str) -> int | None:
|
||||||
|
"""The image id if url is one of our hosted-image URLs (any host), else None."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
m = _PATH.match(urlsplit(url).path)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"""The post-commit image-fetch phase (SD-0002 §6.5.4, §6.9).
|
||||||
|
|
||||||
|
In-process, bounded-concurrency, resumable. For a run's pending images: fetch
|
||||||
|
each URL behind an SSRF guard with INV-18 bounds, run platform/images, store
|
||||||
|
renditions via the objectstore, and move the image pending -> fetched |
|
||||||
|
rejected_* | failed. Per-image idempotent (claim guard), so the phase can die
|
||||||
|
and resume. Lives in the domains layer (imports repo + platform); main.py
|
||||||
|
schedules it post-confirm and runs recovery at startup.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.platform import images as images_mod
|
||||||
|
from app.platform import telemetry
|
||||||
|
|
||||||
|
from . import repo
|
||||||
|
|
||||||
|
MAX_FETCH_BYTES = 20 * 1024 * 1024 # INV-18
|
||||||
|
FETCH_TIMEOUT_S = 30 # INV-18
|
||||||
|
FETCH_CONCURRENCY = 4 # §6.6
|
||||||
|
_CONTENT_PREFIX = "image/"
|
||||||
|
|
||||||
|
|
||||||
|
class FetchBlocked(Exception):
|
||||||
|
"""SSRF guard or bounds refusal — maps to image status 'failed' with reason."""
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_host(url: str, allow_private: bool) -> None:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
if parts.scheme not in {"http", "https"}:
|
||||||
|
raise FetchBlocked(f"unsupported scheme: {parts.scheme!r}")
|
||||||
|
host = parts.hostname
|
||||||
|
if not host:
|
||||||
|
raise FetchBlocked("no host")
|
||||||
|
if allow_private:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80))
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise FetchBlocked(f"dns failure: {exc}") from exc
|
||||||
|
for info in infos:
|
||||||
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
|
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||||
|
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||||
|
raise FetchBlocked(f"non-public address: {ip}")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_bytes(url: str, allow_private: bool) -> tuple[bytes, str]:
|
||||||
|
"""SSRF-guarded, bounded GET. Returns (bytes, content_type). Raises FetchBlocked."""
|
||||||
|
_guard_host(url, allow_private)
|
||||||
|
with httpx.Client(follow_redirects=False, timeout=FETCH_TIMEOUT_S) as client:
|
||||||
|
with client.stream("GET", url) as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise FetchBlocked(f"http {resp.status_code}")
|
||||||
|
ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||||
|
if not ctype.startswith(_CONTENT_PREFIX):
|
||||||
|
raise FetchBlocked(f"not an image content-type: {ctype!r}")
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
total = 0
|
||||||
|
for chunk in resp.iter_bytes():
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_FETCH_BYTES:
|
||||||
|
raise FetchBlocked("oversize")
|
||||||
|
chunks.append(chunk)
|
||||||
|
return b"".join(chunks), ctype
|
||||||
|
|
||||||
|
|
||||||
|
def _key(storefront_id: int, image_id: int, name: str) -> str:
|
||||||
|
return f"storefronts/{storefront_id}/product-images/{image_id}/{name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _process_one(pool, store, image: dict, allow_private: bool) -> None:
|
||||||
|
with pool.connection() as conn:
|
||||||
|
claimed = repo.claim_image_for_fetch(conn, image["id"])
|
||||||
|
conn.commit()
|
||||||
|
if not claimed:
|
||||||
|
return
|
||||||
|
image_id, sf = image["id"], image["storefront_id"]
|
||||||
|
try:
|
||||||
|
data, ctype = fetch_bytes(image["source_url"], allow_private)
|
||||||
|
except (FetchBlocked, httpx.HTTPError) as exc:
|
||||||
|
with pool.connection() as conn:
|
||||||
|
repo.mark_image_failed(conn, image_id, str(exc)[:200]); conn.commit()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = images_mod.process(data)
|
||||||
|
if isinstance(result, images_mod.Rejected):
|
||||||
|
reason = ("below the resolution bar" if result.reason == "rejected_low_res"
|
||||||
|
else "not a supported image")
|
||||||
|
with pool.connection() as conn:
|
||||||
|
repo.mark_image_rejected(conn, image_id, result.reason, reason); conn.commit()
|
||||||
|
return
|
||||||
|
keys = {"original": _key(sf, image_id, "original"),
|
||||||
|
"thumb": _key(sf, image_id, "thumb.webp"),
|
||||||
|
"card": _key(sf, image_id, "card.webp"),
|
||||||
|
"detail": _key(sf, image_id, "detail.webp")}
|
||||||
|
store.put(keys["original"], data, ctype)
|
||||||
|
store.put(keys["thumb"], result.renditions["thumb"], "image/webp")
|
||||||
|
store.put(keys["card"], result.renditions["card"], "image/webp")
|
||||||
|
store.put(keys["detail"], result.renditions["detail"], "image/webp")
|
||||||
|
with pool.connection() as conn:
|
||||||
|
repo.mark_image_fetched(conn, image_id, keys); conn.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001 — one bad image must never wedge the run (review fix)
|
||||||
|
with pool.connection() as conn:
|
||||||
|
repo.mark_image_failed(conn, image_id, f"processing error: {type(exc).__name__}")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def run_image_phase(pool, store, run_id: int, allow_private: bool) -> None:
|
||||||
|
"""Fetch all pending images of one run, then move the run to a terminal status."""
|
||||||
|
started = time.monotonic()
|
||||||
|
with pool.connection() as conn:
|
||||||
|
pending = repo.pending_images_for_run(conn, run_id)
|
||||||
|
if pending:
|
||||||
|
with ThreadPoolExecutor(max_workers=FETCH_CONCURRENCY) as pool_x:
|
||||||
|
list(pool_x.map(lambda img: _process_one(pool, store, img, allow_private), pending))
|
||||||
|
with pool.connection() as conn:
|
||||||
|
counts = repo.run_image_counts(conn, run_id)
|
||||||
|
status = ("complete_with_problems"
|
||||||
|
if counts["rejected"] + counts["failed"] > 0 else "complete")
|
||||||
|
repo.set_run_status(conn, run_id, status)
|
||||||
|
conn.commit()
|
||||||
|
telemetry.emit("image_phase_completed", run_id=run_id, fetched=counts["fetched"],
|
||||||
|
rejected=counts["rejected"], failed=counts["failed"],
|
||||||
|
duration_ms=int((time.monotonic() - started) * 1000))
|
||||||
|
|
||||||
|
|
||||||
|
def recover_incomplete_runs(pool, store, allow_private: bool) -> list[int]:
|
||||||
|
"""Startup scan (§6.9): resume any run stuck in fetching_images. Returns the ids."""
|
||||||
|
with pool.connection() as conn:
|
||||||
|
runs = repo.incomplete_runs(conn)
|
||||||
|
resumed: list[int] = []
|
||||||
|
for run in runs:
|
||||||
|
with pool.connection() as conn:
|
||||||
|
pending = len(repo.pending_images_for_run(conn, run["id"]))
|
||||||
|
telemetry.emit("image_phase_recovered", run_id=run["id"], pending_resumed=pending)
|
||||||
|
run_image_phase(pool, store, run["id"], allow_private)
|
||||||
|
resumed.append(run["id"])
|
||||||
|
return resumed
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Canonical row model + column registry — the one model every dialect maps to (INV-17)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# §6.5.1 canonical columns, by level. Header detection, unknown-column warnings, and
|
||||||
|
# validation all read from this registry.
|
||||||
|
PRODUCT_COLUMNS: dict[str, str] = {
|
||||||
|
# column -> product field name
|
||||||
|
"Title": "title",
|
||||||
|
"Description": "description_html",
|
||||||
|
"Vendor": "vendor",
|
||||||
|
"Type": "product_type",
|
||||||
|
"Google Product Category": "google_product_category",
|
||||||
|
"Tags": "tags",
|
||||||
|
"Status": "status",
|
||||||
|
"Published": "published",
|
||||||
|
"Option1 Name": "option1_name",
|
||||||
|
"Option2 Name": "option2_name",
|
||||||
|
"Option3 Name": "option3_name",
|
||||||
|
}
|
||||||
|
VARIANT_COLUMNS: dict[str, str] = {
|
||||||
|
"Variant SKU": "sku",
|
||||||
|
"Variant Barcode": "barcode",
|
||||||
|
"Variant Price": "price",
|
||||||
|
"Variant Cost": "cost",
|
||||||
|
"Variant Weight": "weight",
|
||||||
|
"Variant Weight Unit": "weight_unit",
|
||||||
|
"Variant Volume": "volume",
|
||||||
|
"Variant Volume Unit": "volume_unit",
|
||||||
|
"Variant Tax ID 1": "tax_id_1",
|
||||||
|
"Variant Tax ID 2": "tax_id_2",
|
||||||
|
"Variant Inventory Tracker": "inventory_tracker",
|
||||||
|
"Variant Inventory Qty": "inventory_qty",
|
||||||
|
"Variant Position": "position",
|
||||||
|
"Variant Image": "variant_image",
|
||||||
|
}
|
||||||
|
OPTION_VALUE_COLUMNS = ("Option1 Value", "Option2 Value", "Option3 Value")
|
||||||
|
IMAGE_COLUMNS = ("Image Src", "Image Position", "Image Alt Text")
|
||||||
|
COMPONENT_COLUMNS = tuple(
|
||||||
|
f"Component {i} {kind}" for i in range(1, 11) for kind in ("SKU", "Quantity")
|
||||||
|
)
|
||||||
|
KNOWN_COLUMNS = (
|
||||||
|
{"Handle"}
|
||||||
|
| set(PRODUCT_COLUMNS)
|
||||||
|
| set(VARIANT_COLUMNS)
|
||||||
|
| set(OPTION_VALUE_COLUMNS)
|
||||||
|
| set(IMAGE_COLUMNS)
|
||||||
|
| set(COMPONENT_COLUMNS)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clearing a field (present-but-empty cell, §6.5.1) resets it to its default.
|
||||||
|
CLEAR_DEFAULTS: dict[str, object] = {
|
||||||
|
"status": "active",
|
||||||
|
"published": True,
|
||||||
|
"product_type": "standalone",
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_DATA_ROWS = 5_000 # INV-18
|
||||||
|
MAX_FILE_BYTES = 10 * 1024 * 1024 # INV-18
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Row:
|
||||||
|
"""One CSV data row: 1-based file line number + the cells of known columns
|
||||||
|
present in the header (column name -> raw string, possibly empty)."""
|
||||||
|
|
||||||
|
line_number: int
|
||||||
|
cells: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParsedFile:
|
||||||
|
dialect: str
|
||||||
|
header: list[str]
|
||||||
|
unknown_columns: list[str]
|
||||||
|
rows: list[Row]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RowError:
|
||||||
|
line_number: int
|
||||||
|
column: str | None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_json(self) -> dict:
|
||||||
|
return {"line": self.line_number, "column": self.column, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanonicalVariant:
|
||||||
|
line_number: int
|
||||||
|
options: tuple[str | None, str | None, str | None]
|
||||||
|
# field name -> normalized value; present only for columns in the file.
|
||||||
|
# value None == clear (reset to default/NULL).
|
||||||
|
fields: dict[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanonicalImage:
|
||||||
|
line_number: int
|
||||||
|
source_url: str
|
||||||
|
position: int
|
||||||
|
alt_text: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanonicalProduct:
|
||||||
|
first_line: int
|
||||||
|
handle: str
|
||||||
|
title: str # "" when missing (the block then carries an error)
|
||||||
|
option_names: tuple[str | None, str | None, str | None] = (None, None, None)
|
||||||
|
fields: dict[str, object] = field(default_factory=dict) # product-level, same semantics
|
||||||
|
variants: list[CanonicalVariant] = field(default_factory=list)
|
||||||
|
images: list[CanonicalImage] = field(default_factory=list)
|
||||||
|
errors: list[RowError] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid(self) -> bool:
|
||||||
|
return not self.errors
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
"""products repo — the SQL layer for the import spine (SD-0002 §6.3 data model).
|
||||||
|
|
||||||
|
Owns SQL only: the catalog snapshot the diff engine reads, import draft/run
|
||||||
|
CRUD, and the apply primitives the confirm transaction calls. Business rules
|
||||||
|
live in service.py and diff.py — nothing here validates, diffs, commits, or
|
||||||
|
rolls back (the confirm flow runs the apply primitives inside its own
|
||||||
|
transaction). Every catalog/draft/run query is storefront-scoped (INV-14).
|
||||||
|
|
||||||
|
Dict payload conventions: functions feeding §6.4 API payloads (insert_draft,
|
||||||
|
list_runs, get_run) return datetimes as `.isoformat()` strings; get_draft_row
|
||||||
|
returns raw datetimes for the service's expiry check. TEXT[] columns bind/load
|
||||||
|
as Python lists and NUMERIC loads as Decimal natively under psycopg 3.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg import sql
|
||||||
|
from psycopg.types.json import Jsonb
|
||||||
|
|
||||||
|
from .diff import CatalogImage, CatalogProduct, CatalogVariant
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catalog snapshot (diff input) + dashboard counts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, CatalogProduct]:
|
||||||
|
"""The storefront's full catalog, keyed by handle, in diff.py's snapshot shape."""
|
||||||
|
catalog: dict[str, CatalogProduct] = {}
|
||||||
|
by_id: dict[int, CatalogProduct] = {}
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT id, handle, title, description_html, vendor, product_type,"
|
||||||
|
" google_product_category, tags, status, published,"
|
||||||
|
" option1_name, option2_name, option3_name"
|
||||||
|
" FROM product WHERE storefront_id = %s",
|
||||||
|
(storefront_id,),
|
||||||
|
):
|
||||||
|
product = CatalogProduct(
|
||||||
|
id=row[0],
|
||||||
|
handle=row[1],
|
||||||
|
title=row[2],
|
||||||
|
option_names=(row[10], row[11], row[12]),
|
||||||
|
fields={
|
||||||
|
"title": row[2],
|
||||||
|
"description_html": row[3],
|
||||||
|
"vendor": row[4],
|
||||||
|
"product_type": row[5],
|
||||||
|
"google_product_category": row[6],
|
||||||
|
"tags": row[7],
|
||||||
|
"status": row[8],
|
||||||
|
"published": row[9],
|
||||||
|
},
|
||||||
|
variants=[],
|
||||||
|
images=[],
|
||||||
|
)
|
||||||
|
catalog[product.handle] = product
|
||||||
|
by_id[product.id] = product
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT v.product_id, v.id, v.position,"
|
||||||
|
" v.option1_value, v.option2_value, v.option3_value,"
|
||||||
|
" v.sku, v.barcode, v.price, v.cost, v.weight, v.weight_unit,"
|
||||||
|
" v.volume, v.volume_unit, v.tax_id_1, v.tax_id_2,"
|
||||||
|
" v.inventory_tracker, v.inventory_qty, i.source_url"
|
||||||
|
" FROM variant v"
|
||||||
|
" JOIN product p ON p.id = v.product_id"
|
||||||
|
" LEFT JOIN product_image i ON i.id = v.image_id"
|
||||||
|
" WHERE p.storefront_id = %s"
|
||||||
|
" ORDER BY v.product_id, v.position, v.id",
|
||||||
|
(storefront_id,),
|
||||||
|
):
|
||||||
|
by_id[row[0]].variants.append(
|
||||||
|
CatalogVariant(
|
||||||
|
id=row[1],
|
||||||
|
options=(row[3], row[4], row[5]),
|
||||||
|
position=row[2],
|
||||||
|
fields={
|
||||||
|
"sku": row[6],
|
||||||
|
"barcode": row[7],
|
||||||
|
"price": row[8],
|
||||||
|
"cost": row[9],
|
||||||
|
"weight": row[10],
|
||||||
|
"weight_unit": row[11],
|
||||||
|
"volume": row[12],
|
||||||
|
"volume_unit": row[13],
|
||||||
|
"tax_id_1": row[14],
|
||||||
|
"tax_id_2": row[15],
|
||||||
|
"inventory_tracker": row[16],
|
||||||
|
"inventory_qty": row[17],
|
||||||
|
"variant_image": row[18],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text, i.status"
|
||||||
|
" FROM product_image i"
|
||||||
|
" JOIN product p ON p.id = i.product_id"
|
||||||
|
" WHERE p.storefront_id = %s"
|
||||||
|
" ORDER BY i.product_id, i.position, i.id",
|
||||||
|
(storefront_id,),
|
||||||
|
):
|
||||||
|
by_id[row[0]].images.append(
|
||||||
|
CatalogImage(id=row[1], source_url=row[2], position=row[3],
|
||||||
|
alt_text=row[4], status=row[5])
|
||||||
|
)
|
||||||
|
return catalog
|
||||||
|
|
||||||
|
|
||||||
|
_EXPORT_STATUSES = ("all", "active", "draft", "archived")
|
||||||
|
|
||||||
|
|
||||||
|
def export_catalog(
|
||||||
|
conn: psycopg.Connection, storefront_id: int, status_filter: str
|
||||||
|
) -> list[CatalogProduct]:
|
||||||
|
"""The storefront's catalog as an ordered snapshot list, optionally filtered
|
||||||
|
by product status (PUC-9). Reuses load_catalog's snapshot builder; the
|
||||||
|
catalog fits in memory (≤5k rows, INV-18). Ordered by handle for a stable,
|
||||||
|
deterministic export."""
|
||||||
|
catalog = load_catalog(conn, storefront_id)
|
||||||
|
products = sorted(catalog.values(), key=lambda p: p.handle)
|
||||||
|
if status_filter and status_filter != "all":
|
||||||
|
products = [p for p in products if p.fields.get("status") == status_filter]
|
||||||
|
return products
|
||||||
|
|
||||||
|
|
||||||
|
def product_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,)
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def image_problem_count(conn: psycopg.Connection, storefront_id: int) -> int:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT count(*) FROM product_image i"
|
||||||
|
" JOIN product p ON p.id = i.product_id"
|
||||||
|
" WHERE p.storefront_id = %s"
|
||||||
|
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')",
|
||||||
|
(storefront_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def latest_run_id(conn: psycopg.Connection, storefront_id: int) -> int | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM import_run WHERE storefront_id = %s"
|
||||||
|
" ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||||
|
(storefront_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import drafts (preview server side, INV-11)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def insert_draft(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
storefront_id: int,
|
||||||
|
account_id: int,
|
||||||
|
file_name: str,
|
||||||
|
dialect: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
summary: dict,
|
||||||
|
records: list,
|
||||||
|
fingerprint: str,
|
||||||
|
unknown_columns: list[str],
|
||||||
|
) -> dict:
|
||||||
|
"""Create a draft (expires in 1 hour); returns the §6.4 draft payload."""
|
||||||
|
row = conn.execute(
|
||||||
|
"INSERT INTO import_draft"
|
||||||
|
" (storefront_id, account_id, file_name, dialect, file_bytes,"
|
||||||
|
" summary, records, fingerprint, unknown_columns, expires_at)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, now() + interval '1 hour')"
|
||||||
|
" RETURNING id, expires_at",
|
||||||
|
(
|
||||||
|
storefront_id,
|
||||||
|
account_id,
|
||||||
|
file_name,
|
||||||
|
dialect,
|
||||||
|
file_bytes,
|
||||||
|
Jsonb(summary),
|
||||||
|
Jsonb(records),
|
||||||
|
fingerprint,
|
||||||
|
unknown_columns,
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"file_name": file_name,
|
||||||
|
"dialect": dialect,
|
||||||
|
"summary": summary,
|
||||||
|
"unknown_columns": unknown_columns,
|
||||||
|
"expires_at": row[1].isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, storefront_id, account_id, file_name, dialect, file_bytes,"
|
||||||
|
" summary, records, fingerprint, unknown_columns, expires_at, created_at"
|
||||||
|
" FROM import_draft WHERE id = %s AND storefront_id = %s",
|
||||||
|
(draft_id, storefront_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
columns = (
|
||||||
|
"id",
|
||||||
|
"storefront_id",
|
||||||
|
"account_id",
|
||||||
|
"file_name",
|
||||||
|
"dialect",
|
||||||
|
"file_bytes",
|
||||||
|
"summary",
|
||||||
|
"records",
|
||||||
|
"fingerprint",
|
||||||
|
"unknown_columns",
|
||||||
|
"expires_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
record = dict(zip(columns, row))
|
||||||
|
# BYTEA loads as memoryview; the service expects bytes.
|
||||||
|
record["file_bytes"] = bytes(record["file_bytes"])
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def draft_records(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
storefront_id: int,
|
||||||
|
draft_id: int,
|
||||||
|
kind: str | None,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The draft's preview records, order-preserving, optionally filtered by kind."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT rec FROM import_draft d,"
|
||||||
|
" jsonb_array_elements(d.records) WITH ORDINALITY AS r(rec, ord)"
|
||||||
|
" WHERE d.id = %(draft_id)s AND d.storefront_id = %(storefront_id)s"
|
||||||
|
" AND (%(kind)s::text IS NULL OR rec->>'kind' = %(kind)s)"
|
||||||
|
" ORDER BY ord LIMIT %(limit)s OFFSET %(offset)s",
|
||||||
|
{
|
||||||
|
"draft_id": draft_id,
|
||||||
|
"storefront_id": storefront_id,
|
||||||
|
"kind": kind,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
},
|
||||||
|
).fetchall()
|
||||||
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def delete_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM import_draft WHERE id = %s AND storefront_id = %s",
|
||||||
|
(draft_id, storefront_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_expired_drafts(conn: psycopg.Connection) -> None:
|
||||||
|
conn.execute("DELETE FROM import_draft WHERE expires_at < now()")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Import runs (history, PUC-8)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_TERMINAL_RUN_STATUSES = ("complete", "complete_with_problems")
|
||||||
|
|
||||||
|
|
||||||
|
def insert_run(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
storefront_id: int,
|
||||||
|
account_id: int,
|
||||||
|
file_name: str,
|
||||||
|
dialect: str,
|
||||||
|
added: int,
|
||||||
|
updated: int,
|
||||||
|
errored: int,
|
||||||
|
status: str,
|
||||||
|
) -> int:
|
||||||
|
return conn.execute(
|
||||||
|
"INSERT INTO import_run"
|
||||||
|
" (storefront_id, account_id, file_name, dialect,"
|
||||||
|
" products_added, products_updated, rows_errored, status, completed_at)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END)"
|
||||||
|
" RETURNING id",
|
||||||
|
(
|
||||||
|
storefront_id,
|
||||||
|
account_id,
|
||||||
|
file_name,
|
||||||
|
dialect,
|
||||||
|
added,
|
||||||
|
updated,
|
||||||
|
errored,
|
||||||
|
status,
|
||||||
|
status in _TERMINAL_RUN_STATUSES,
|
||||||
|
),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def insert_run_errors(conn: psycopg.Connection, run_id: int, errors: list[dict]) -> None:
|
||||||
|
"""Record per-row errors (RowError.as_json shape: line/column/message)."""
|
||||||
|
if not errors:
|
||||||
|
return
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO import_run_error (run_id, line_number, column_name, message)"
|
||||||
|
" VALUES (%s, %s, %s, %s)",
|
||||||
|
[(run_id, e["line"], e["column"], e["message"]) for e in errors],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_RUN_SELECT = (
|
||||||
|
"SELECT r.id, r.file_name, r.dialect, r.created_at, r.completed_at, r.status,"
|
||||||
|
" a.email, r.products_added, r.products_updated, r.rows_errored"
|
||||||
|
" FROM import_run r JOIN account a ON a.id = r.account_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dict(row: tuple) -> dict:
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"file_name": row[1],
|
||||||
|
"dialect": row[2],
|
||||||
|
"created_at": row[3].isoformat(),
|
||||||
|
"completed_at": row[4].isoformat() if row[4] is not None else None,
|
||||||
|
"status": row[5],
|
||||||
|
"by": row[6],
|
||||||
|
"products_added": row[7],
|
||||||
|
"products_updated": row[8],
|
||||||
|
"rows_errored": row[9],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_runs(conn: psycopg.Connection, storefront_id: int, limit: int, offset: int) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
_RUN_SELECT + " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC"
|
||||||
|
" LIMIT %s OFFSET %s",
|
||||||
|
(storefront_id, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
runs = [_run_dict(row) for row in rows]
|
||||||
|
if not runs:
|
||||||
|
return runs
|
||||||
|
ids = [r["id"] for r in runs]
|
||||||
|
by_run = {rid: {"fetched": 0, "rejected": 0, "failed": 0, "total": 0} for rid in ids}
|
||||||
|
for rid, fetched, rejected, failed, total in conn.execute(
|
||||||
|
"SELECT import_run_id,"
|
||||||
|
" count(*) FILTER (WHERE status='fetched'),"
|
||||||
|
" count(*) FILTER (WHERE status IN ('rejected_low_res','rejected_not_image')),"
|
||||||
|
" count(*) FILTER (WHERE status='failed'), count(*)"
|
||||||
|
" FROM product_image WHERE import_run_id = ANY(%s) GROUP BY import_run_id",
|
||||||
|
(ids,),
|
||||||
|
):
|
||||||
|
by_run[rid] = {"fetched": fetched, "rejected": rejected, "failed": failed, "total": total}
|
||||||
|
for r in runs:
|
||||||
|
r["image_counts"] = by_run[r["id"]]
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None:
|
||||||
|
row = conn.execute(
|
||||||
|
_RUN_SELECT + " WHERE r.storefront_id = %s AND r.id = %s",
|
||||||
|
(storefront_id, run_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
run = _run_dict(row)
|
||||||
|
run["errors"] = [
|
||||||
|
{"line": line, "column": column, "message": message}
|
||||||
|
for line, column, message in conn.execute(
|
||||||
|
"SELECT line_number, column_name, message FROM import_run_error"
|
||||||
|
" WHERE run_id = %s ORDER BY line_number, id",
|
||||||
|
(run_id,),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
counts = run_image_counts(conn, run_id)
|
||||||
|
run["image_progress"] = {"done": counts["total"] - counts["pending"], "total": counts["total"]}
|
||||||
|
run["image_counts"] = {"fetched": counts["fetched"], "rejected": counts["rejected"],
|
||||||
|
"failed": counts["failed"]}
|
||||||
|
run["image_outcomes"] = run_image_outcomes(conn, run_id)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image fetch phase (SLICE-7, SD-0002 §6.5.4) — claim/mark/count/outcomes.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pending_images_for_run(conn: psycopg.Connection, run_id: int) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT i.id, i.source_url, p.storefront_id, i.product_id"
|
||||||
|
" FROM product_image i JOIN product p ON p.id = i.product_id"
|
||||||
|
" WHERE i.import_run_id = %s AND i.status = 'pending' ORDER BY i.id",
|
||||||
|
(run_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [{"id": r[0], "source_url": r[1], "storefront_id": r[2], "product_id": r[3]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def claim_image_for_fetch(conn: psycopg.Connection, image_id: int) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"UPDATE product_image SET status = 'pending' WHERE id = %s AND status = 'pending' RETURNING id",
|
||||||
|
(image_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_image_fetched(conn: psycopg.Connection, image_id: int, keys: dict[str, str]) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE product_image SET status = 'fetched', failure_reason = NULL,"
|
||||||
|
" key_original = %s, key_thumb = %s, key_card = %s, key_detail = %s,"
|
||||||
|
" fetched_at = now() WHERE id = %s",
|
||||||
|
(keys["original"], keys["thumb"], keys["card"], keys["detail"], image_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_image_rejected(conn: psycopg.Connection, image_id: int, status: str, reason: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE product_image SET status = %s, failure_reason = %s, fetched_at = now() WHERE id = %s",
|
||||||
|
(status, reason, image_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_image_failed(conn: psycopg.Connection, image_id: int, reason: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE product_image SET status = 'failed', failure_reason = %s, fetched_at = now() WHERE id = %s",
|
||||||
|
(reason, image_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_image_counts(conn: psycopg.Connection, run_id: int) -> dict:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT count(*) FILTER (WHERE status = 'fetched'),"
|
||||||
|
" count(*) FILTER (WHERE status IN ('rejected_low_res', 'rejected_not_image')),"
|
||||||
|
" count(*) FILTER (WHERE status = 'failed'),"
|
||||||
|
" count(*) FILTER (WHERE status = 'pending'), count(*)"
|
||||||
|
" FROM product_image WHERE import_run_id = %s",
|
||||||
|
(run_id,),
|
||||||
|
).fetchone()
|
||||||
|
return {"fetched": row[0], "rejected": row[1], "failed": row[2], "pending": row[3], "total": row[4]}
|
||||||
|
|
||||||
|
|
||||||
|
def set_run_status(conn: psycopg.Connection, run_id: int, status: str) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE import_run SET status = %s,"
|
||||||
|
" completed_at = CASE WHEN %s THEN now() ELSE completed_at END WHERE id = %s",
|
||||||
|
(status, status in _TERMINAL_RUN_STATUSES, run_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def incomplete_runs(conn: psycopg.Connection) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, storefront_id FROM import_run WHERE status = 'fetching_images' ORDER BY id",
|
||||||
|
).fetchall()
|
||||||
|
return [{"id": r[0], "storefront_id": r[1]} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def image_for_serving(conn: psycopg.Connection, storefront_id: int, image_id: int) -> dict | None:
|
||||||
|
"""Storefront-scoped lookup for the serving route: status + rendition keys (INV-14)."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT i.status, i.key_original, i.key_thumb, i.key_card, i.key_detail"
|
||||||
|
" FROM product_image i JOIN product p ON p.id = i.product_id"
|
||||||
|
" WHERE i.id = %s AND p.storefront_id = %s",
|
||||||
|
(image_id, storefront_id),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {"status": row[0], "original": row[1], "thumb": row[2], "card": row[3], "detail": row[4]}
|
||||||
|
|
||||||
|
|
||||||
|
def run_image_outcomes(conn: psycopg.Connection, run_id: int) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT p.handle, i.source_url, i.status, i.failure_reason,"
|
||||||
|
" v.option1_value, v.option2_value, v.option3_value"
|
||||||
|
" FROM product_image i JOIN product p ON p.id = i.product_id"
|
||||||
|
" LEFT JOIN variant v ON v.image_id = i.id"
|
||||||
|
" WHERE i.import_run_id = %s"
|
||||||
|
" AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')"
|
||||||
|
" ORDER BY p.handle, i.position, i.id",
|
||||||
|
(run_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{"handle": r[0], "url": r[1], "outcome": r[2], "reason": r[3],
|
||||||
|
"variant": " / ".join(x for x in (r[4], r[5], r[6]) if x) or None}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Apply primitives — called inside the confirm transaction (Task 8); no commits.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def insert_product(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
storefront_id: int,
|
||||||
|
handle: str,
|
||||||
|
resolved_fields: dict,
|
||||||
|
option_names: tuple[str | None, str | None, str | None],
|
||||||
|
) -> int:
|
||||||
|
"""INSERT with only the file-present fields; absent ones take column defaults."""
|
||||||
|
columns = ["storefront_id", "handle", "option1_name", "option2_name", "option3_name"]
|
||||||
|
values: list[object] = [storefront_id, handle, *option_names]
|
||||||
|
for field_name, value in resolved_fields.items():
|
||||||
|
columns.append(field_name)
|
||||||
|
values.append(value)
|
||||||
|
query = sql.SQL("INSERT INTO product ({}) VALUES ({}) RETURNING id").format(
|
||||||
|
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
||||||
|
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||||
|
)
|
||||||
|
return conn.execute(query, values).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def update_product(conn: psycopg.Connection, product_id: int, changed_fields: dict) -> None:
|
||||||
|
if not changed_fields:
|
||||||
|
return
|
||||||
|
assignments = [
|
||||||
|
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
|
||||||
|
for f in changed_fields
|
||||||
|
]
|
||||||
|
query = sql.SQL("UPDATE product SET {}, updated_at = now() WHERE id = {}").format(
|
||||||
|
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||||
|
)
|
||||||
|
conn.execute(query, [*changed_fields.values(), product_id])
|
||||||
|
|
||||||
|
|
||||||
|
def insert_variant(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
product_id: int,
|
||||||
|
position: int,
|
||||||
|
options: tuple[str | None, str | None, str | None],
|
||||||
|
resolved_fields: dict,
|
||||||
|
image_id: int | None,
|
||||||
|
) -> int:
|
||||||
|
# variant_image is not a column — the caller translates it to image_id; position
|
||||||
|
# is the explicit param. Filter both defensively.
|
||||||
|
fields = {
|
||||||
|
k: v for k, v in resolved_fields.items() if k != "variant_image" and k != "position"
|
||||||
|
}
|
||||||
|
columns = [
|
||||||
|
"product_id",
|
||||||
|
"position",
|
||||||
|
"option1_value",
|
||||||
|
"option2_value",
|
||||||
|
"option3_value",
|
||||||
|
"image_id",
|
||||||
|
]
|
||||||
|
values: list[object] = [product_id, position, *options, image_id]
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
columns.append(field_name)
|
||||||
|
values.append(value)
|
||||||
|
query = sql.SQL("INSERT INTO variant ({}) VALUES ({}) RETURNING id").format(
|
||||||
|
sql.SQL(", ").join(sql.Identifier(c) for c in columns),
|
||||||
|
sql.SQL(", ").join(sql.Placeholder() for _ in columns),
|
||||||
|
)
|
||||||
|
return conn.execute(query, values).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def update_variant(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
variant_id: int,
|
||||||
|
changed_fields: dict,
|
||||||
|
image_id: int | None | type(...) = ...,
|
||||||
|
) -> None:
|
||||||
|
"""Dynamic UPDATE; image_id's Ellipsis default means "don't touch image_id"."""
|
||||||
|
fields = {
|
||||||
|
k: v for k, v in changed_fields.items() if k != "variant_image"
|
||||||
|
}
|
||||||
|
assignments = [
|
||||||
|
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) for f in fields
|
||||||
|
]
|
||||||
|
values: list[object] = list(fields.values())
|
||||||
|
if image_id is not ...:
|
||||||
|
assignments.append(sql.SQL("image_id = {}").format(sql.Placeholder()))
|
||||||
|
values.append(image_id)
|
||||||
|
if not assignments:
|
||||||
|
return
|
||||||
|
query = sql.SQL("UPDATE variant SET {}, updated_at = now() WHERE id = {}").format(
|
||||||
|
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||||
|
)
|
||||||
|
conn.execute(query, [*values, variant_id])
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_image(
|
||||||
|
conn: psycopg.Connection,
|
||||||
|
product_id: int,
|
||||||
|
source_url: str,
|
||||||
|
position: int,
|
||||||
|
alt_text: str | None,
|
||||||
|
run_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Image identity within a product is source_url (§6.3); existing rows are
|
||||||
|
returned untouched — diff emits explicit image update entries for position/alt."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM product_image WHERE product_id = %s AND source_url = %s",
|
||||||
|
(product_id, source_url),
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
return row[0]
|
||||||
|
return conn.execute(
|
||||||
|
"INSERT INTO product_image (product_id, source_url, position, alt_text, import_run_id)"
|
||||||
|
" VALUES (%s, %s, %s, %s, %s) RETURNING id",
|
||||||
|
(product_id, source_url, position, alt_text, run_id),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def update_image(conn: psycopg.Connection, image_id: int, changed_fields: dict) -> None:
|
||||||
|
"""Subset of {position, alt_text}."""
|
||||||
|
if not changed_fields:
|
||||||
|
return
|
||||||
|
assignments = [
|
||||||
|
sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder())
|
||||||
|
for f in changed_fields
|
||||||
|
]
|
||||||
|
query = sql.SQL("UPDATE product_image SET {} WHERE id = {}").format(
|
||||||
|
sql.SQL(", ").join(assignments), sql.Placeholder()
|
||||||
|
)
|
||||||
|
conn.execute(query, [*changed_fields.values(), image_id])
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
Handle,Title,Description,Vendor,Type,Google Product Category,Tags,Status,Published,Option1 Name,Option1 Value,Option2 Name,Option2 Value,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position,Image Alt Text
|
||||||
|
moon-mug,Moon Mug,"<p>A ceramic mug glazed in moonlight grey.</p>",Wiggle Goods,standalone,Home & Garden > Kitchen & Dining,"kitchen, mugs",active,TRUE,,,,,WG-MUG-001,18.00,40,https://images.example.com/moon-mug.jpg,1,Moon Mug on a desk
|
||||||
|
star-tee,Star Tee,"<p>Soft cotton tee with a hand-printed star.</p>",Wiggle Goods,standalone,Apparel & Accessories > Clothing,"apparel, tees",active,TRUE,Size,S,Color,Indigo,WG-TEE-S,24.00,12,https://images.example.com/star-tee.jpg,1,Star Tee flat lay
|
||||||
|
star-tee,,,,,,,,,,M,,Indigo,WG-TEE-M,24.00,18,,,
|
||||||
|
star-tee,,,,,,,,,,L,,Indigo,WG-TEE-L,26.00,9,,,
|
||||||
|
star-tee,,,,,,,,,,,,,,,,https://images.example.com/star-tee-back.jpg,2,Star Tee back print
|
||||||
|
@@ -0,0 +1,125 @@
|
|||||||
|
"""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 .hosted import EXPORT_RENDITION, image_url
|
||||||
|
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], base_url: str = "") -> 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, base_url):
|
||||||
|
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, base_url: str = "") -> 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], base_url)
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _write_image(row: dict[str, str], image, base_url: str) -> None:
|
||||||
|
# Fetched images export the platform-hosted URL (INV-12/16); everything
|
||||||
|
# else keeps the original source_url so nothing is lost (§6.5.5).
|
||||||
|
if image.status == "fetched":
|
||||||
|
row["Image Src"] = image_url(base_url, image.id, EXPORT_RENDITION)
|
||||||
|
else:
|
||||||
|
row["Image Src"] = image.source_url
|
||||||
|
row["Image Position"] = str(image.position)
|
||||||
|
if image.alt_text is not None:
|
||||||
|
row["Image Alt Text"] = image.alt_text
|
||||||
|
|
||||||
|
|
||||||
|
def _write_variant(row: dict[str, str], product: CatalogProduct, variant) -> None:
|
||||||
|
"""Fill a row's option-value + variant columns for one variant."""
|
||||||
|
for slot, value in enumerate(variant.options, start=1):
|
||||||
|
# Only emit an option value when the product actually has that option
|
||||||
|
# (a no-option product's single variant carries all-NULL options).
|
||||||
|
if product.option_names[slot - 1] and value is not None:
|
||||||
|
row[f"Option{slot} Value"] = value
|
||||||
|
# position is a CatalogVariant attribute, not a fields{} entry — emit it
|
||||||
|
# explicitly. An empty Variant Position cell re-imports as "reset to file
|
||||||
|
# order", so a non-sequential stored position would round-trip to an update
|
||||||
|
# (INV-12). (Like _write_image, which emits its position attribute.)
|
||||||
|
row["Variant Position"] = str(variant.position)
|
||||||
|
for field, col in _VARIANT_FIELD_TO_COL.items():
|
||||||
|
if field in variant.fields:
|
||||||
|
row[col] = _cell(variant.fields[field])
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""products service — the import/export use-case orchestration (SD-0002 §6.5).
|
||||||
|
|
||||||
|
Coordinates codec → validate → diff → repo; owns transaction boundaries (repo
|
||||||
|
never commits). Preview is read-only against catalog tables (INV-11): validation
|
||||||
|
writes exactly one row — the import_draft. TEL events per §9.1.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
from app.platform import config, telemetry
|
||||||
|
|
||||||
|
from . import codec, diff, repo, serialize, validate
|
||||||
|
from .errors import (
|
||||||
|
DraftExpired,
|
||||||
|
DraftNotFound,
|
||||||
|
EmptyCatalog,
|
||||||
|
NothingToApply,
|
||||||
|
PreviewStale,
|
||||||
|
RunNotFound,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||||
|
file_name: str, data: bytes) -> dict:
|
||||||
|
"""Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected."""
|
||||||
|
started = time.monotonic()
|
||||||
|
# Commit the sweep before parsing: a FileRejected mid-parse must not roll
|
||||||
|
# back expired-draft cleanup along with it.
|
||||||
|
repo.sweep_expired_drafts(conn)
|
||||||
|
conn.commit()
|
||||||
|
parsed = codec.parse_csv(data)
|
||||||
|
products = validate.build_products(parsed)
|
||||||
|
catalog = repo.load_catalog(conn, storefront_id)
|
||||||
|
diff_result = diff.compute_diff(catalog, products)
|
||||||
|
draft = repo.insert_draft(
|
||||||
|
conn, storefront_id, account_id, file_name, parsed.dialect, data,
|
||||||
|
diff_result.summary, diff_result.records, diff_result.fingerprint,
|
||||||
|
parsed.unknown_columns,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
telemetry.emit(
|
||||||
|
"import_draft_created",
|
||||||
|
storefront_id=storefront_id,
|
||||||
|
dialect=parsed.dialect,
|
||||||
|
row_count=len(parsed.rows),
|
||||||
|
adds=diff_result.summary["adds"],
|
||||||
|
updates=diff_result.summary["updates"],
|
||||||
|
unchanged=diff_result.summary["unchanged"],
|
||||||
|
errors=diff_result.summary["errors"],
|
||||||
|
unknown_columns_count=len(parsed.unknown_columns),
|
||||||
|
duration_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
return draft
|
||||||
|
|
||||||
|
|
||||||
|
def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
|
||||||
|
"""The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3)."""
|
||||||
|
row = repo.get_draft_row(conn, storefront_id, draft_id)
|
||||||
|
if row is None:
|
||||||
|
raise DraftNotFound()
|
||||||
|
if row["expires_at"] < datetime.now(timezone.utc):
|
||||||
|
repo.delete_draft(conn, storefront_id, draft_id)
|
||||||
|
conn.commit()
|
||||||
|
raise DraftExpired()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict:
|
||||||
|
"""The §6.4 draft payload — never file_bytes or the full records list."""
|
||||||
|
row = _live_draft_row(conn, storefront_id, draft_id)
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"file_name": row["file_name"],
|
||||||
|
"dialect": row["dialect"],
|
||||||
|
"summary": row["summary"],
|
||||||
|
"unknown_columns": row["unknown_columns"],
|
||||||
|
"expires_at": row["expires_at"].isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int,
|
||||||
|
kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]:
|
||||||
|
"""The draft's preview records, paged, optionally filtered by kind (PUC-3)."""
|
||||||
|
_live_draft_row(conn, storefront_id, draft_id)
|
||||||
|
return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset)
|
||||||
|
|
||||||
|
|
||||||
|
def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None:
|
||||||
|
"""Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a)."""
|
||||||
|
repo.delete_draft(conn, storefront_id, draft_id)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int,
|
||||||
|
draft_id: int) -> int:
|
||||||
|
"""Apply the previewed diff in one transaction (PUC-4; INV-10/11).
|
||||||
|
|
||||||
|
Everything is re-derived from the draft's stored file bytes against the live
|
||||||
|
catalog; a fingerprint mismatch means the catalog drifted since preview
|
||||||
|
(PreviewStale — the draft is kept so the merchant can re-validate). The apply
|
||||||
|
executes the typed plan compute_diff built alongside the preview records, so
|
||||||
|
what lands is exactly what the preview showed. rows_errored counts the
|
||||||
|
import_run_error rows recorded (one per RowError), which is what the run
|
||||||
|
detail's error table shows; the preview's errors tile counts error *products*.
|
||||||
|
"""
|
||||||
|
started = time.monotonic()
|
||||||
|
row = _live_draft_row(conn, storefront_id, draft_id)
|
||||||
|
parsed = codec.parse_csv(row["file_bytes"])
|
||||||
|
products = validate.build_products(parsed)
|
||||||
|
catalog = repo.load_catalog(conn, storefront_id)
|
||||||
|
diff_result = diff.compute_diff(catalog, products)
|
||||||
|
if diff_result.fingerprint != row["fingerprint"]:
|
||||||
|
# Release the read snapshot; nothing written.
|
||||||
|
conn.rollback()
|
||||||
|
raise PreviewStale()
|
||||||
|
summary_counts = diff_result.summary
|
||||||
|
if summary_counts["adds"] + summary_counts["updates"] == 0:
|
||||||
|
# Release the read snapshot; nothing written.
|
||||||
|
conn.rollback()
|
||||||
|
raise NothingToApply()
|
||||||
|
error_rows = [
|
||||||
|
error.as_json()
|
||||||
|
for plan in diff_result.plan if plan.kind == "error"
|
||||||
|
for error in plan.canonical.errors
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
run_id = repo.insert_run(
|
||||||
|
conn, storefront_id, account_id, row["file_name"], row["dialect"],
|
||||||
|
added=summary_counts["adds"], updated=summary_counts["updates"],
|
||||||
|
errored=len(error_rows), status="applying",
|
||||||
|
)
|
||||||
|
for plan in diff_result.plan:
|
||||||
|
_apply_product_plan(conn, storefront_id, plan, run_id)
|
||||||
|
repo.insert_run_errors(conn, run_id, error_rows)
|
||||||
|
pending = repo.run_image_counts(conn, run_id)["pending"]
|
||||||
|
repo.set_run_status(conn, run_id, "fetching_images" if pending else "complete")
|
||||||
|
repo.delete_draft(conn, storefront_id, draft_id)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
conn.rollback()
|
||||||
|
telemetry.emit(
|
||||||
|
"import_apply_failed",
|
||||||
|
draft_id=draft_id,
|
||||||
|
storefront_id=storefront_id,
|
||||||
|
error_class=type(exc).__name__,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
telemetry.emit(
|
||||||
|
"import_run_completed",
|
||||||
|
run_id=run_id,
|
||||||
|
storefront_id=storefront_id,
|
||||||
|
added=summary_counts["adds"],
|
||||||
|
updated=summary_counts["updates"],
|
||||||
|
errored=len(error_rows),
|
||||||
|
duration_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_product_plan(conn: psycopg.Connection, storefront_id: int,
|
||||||
|
plan: diff.ProductPlan, run_id: int) -> None:
|
||||||
|
"""Execute one product's plan inside the confirm transaction (no commits here)."""
|
||||||
|
if plan.kind == "add":
|
||||||
|
# Title is a canonical attribute, not a fields{} entry — non-error
|
||||||
|
# products always carry one (validate guarantees it).
|
||||||
|
product_fields = {"title": plan.canonical.title}
|
||||||
|
product_fields.update(diff.resolved_product_fields(plan.canonical))
|
||||||
|
product_id = repo.insert_product(
|
||||||
|
conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names
|
||||||
|
)
|
||||||
|
image_ids: dict[str, int] = {}
|
||||||
|
elif plan.kind == "update":
|
||||||
|
product_id = plan.catalog.id
|
||||||
|
repo.update_product(conn, product_id, plan.product_changes)
|
||||||
|
image_ids = {image.source_url: image.id for image in plan.catalog.images}
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Images first, so variants' variant_image URLs resolve to ids: validate puts
|
||||||
|
# every variant_image URL into canonical.images, so each URL is in either the
|
||||||
|
# catalog map (existing image) or the adds below.
|
||||||
|
for image_plan in plan.image_plans:
|
||||||
|
if image_plan.kind == "add":
|
||||||
|
image_ids[image_plan.source_url] = repo.get_or_create_image(
|
||||||
|
conn, product_id, image_plan.source_url, image_plan.position,
|
||||||
|
image_plan.alt_text, run_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repo.update_image(conn, image_plan.image_id, image_plan.changes)
|
||||||
|
|
||||||
|
for variant_plan in plan.variant_plans:
|
||||||
|
if variant_plan.kind == "add":
|
||||||
|
fields = diff.resolved_variant_fields(variant_plan.canonical, variant_plan.file_order)
|
||||||
|
# diff time resolved any cleared position to file order; the
|
||||||
|
# file_order fallback covers an absent position column.
|
||||||
|
position = fields.get("position") or variant_plan.file_order
|
||||||
|
url = fields.get("variant_image")
|
||||||
|
image_id = image_ids[url] if url else None
|
||||||
|
repo.insert_variant(
|
||||||
|
conn, product_id, position, variant_plan.canonical.options, fields, image_id
|
||||||
|
)
|
||||||
|
elif "variant_image" in variant_plan.changes:
|
||||||
|
url = variant_plan.changes["variant_image"]
|
||||||
|
repo.update_variant(
|
||||||
|
conn, variant_plan.catalog_id, variant_plan.changes,
|
||||||
|
image_id=image_ids[url] if url else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
repo.update_variant(conn, variant_plan.catalog_id, variant_plan.changes)
|
||||||
|
|
||||||
|
|
||||||
|
def list_runs(conn: psycopg.Connection, storefront_id: int,
|
||||||
|
limit: int = 50, offset: int = 0) -> list[dict]:
|
||||||
|
"""The storefront's import history, newest first (PUC-8)."""
|
||||||
|
return repo.list_runs(conn, storefront_id, limit, offset)
|
||||||
|
|
||||||
|
|
||||||
|
def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict:
|
||||||
|
"""One run's §6.4 detail payload, errors included."""
|
||||||
|
run = repo.get_run(conn, storefront_id, run_id)
|
||||||
|
if run is None:
|
||||||
|
raise RunNotFound()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def export_catalog(
|
||||||
|
conn: psycopg.Connection, storefront_id: int, status_filter: str
|
||||||
|
) -> Iterator[str]:
|
||||||
|
"""Stream the storefront's catalog as canonical CSV (PUC-9; INV-12 codec).
|
||||||
|
|
||||||
|
Read-only: builds the snapshot, then streams the serializer over it. TEL-3
|
||||||
|
is emitted once the stream is exhausted, with the product count and elapsed
|
||||||
|
time. Raises EmptyCatalog before yielding anything if the (filtered) catalog
|
||||||
|
is empty, so the BFF can answer 409 cleanly with no partial body.
|
||||||
|
"""
|
||||||
|
started = time.monotonic()
|
||||||
|
snapshot = repo.export_catalog(conn, storefront_id, status_filter)
|
||||||
|
if not snapshot:
|
||||||
|
raise EmptyCatalog()
|
||||||
|
|
||||||
|
def _stream() -> Iterator[str]:
|
||||||
|
yield from serialize.catalog_to_csv(snapshot, base_url=config.public_base_url())
|
||||||
|
telemetry.emit(
|
||||||
|
"catalog_exported",
|
||||||
|
storefront_id=storefront_id,
|
||||||
|
status_filter=status_filter,
|
||||||
|
product_count=len(snapshot),
|
||||||
|
duration_ms=int((time.monotonic() - started) * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _stream()
|
||||||
|
|
||||||
|
|
||||||
|
def summary(conn: psycopg.Connection, storefront_id: int) -> dict:
|
||||||
|
"""The products dashboard counts (§6.4)."""
|
||||||
|
return {
|
||||||
|
"product_count": repo.product_count(conn, storefront_id),
|
||||||
|
"image_problem_count": repo.image_problem_count(conn, storefront_id),
|
||||||
|
"latest_run_id": repo.latest_run_id(conn, storefront_id),
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)."""
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
"""ecomm backend — FastAPI app factory + the REST BFF.
|
||||||
|
|
||||||
|
SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF
|
||||||
|
translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in
|
||||||
|
the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds
|
||||||
|
the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the
|
||||||
|
_storefront_for seam from the storefronts domain. SLICE-5 adds the /api/products/* import
|
||||||
|
spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from fastapi import Depends, FastAPI, File, Query, Response, UploadFile
|
||||||
|
from fastapi import Path as ApiPath
|
||||||
|
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.domains import accounts, products, storefronts
|
||||||
|
from app.platform import config, db
|
||||||
|
from app.platform import mailer as mailer_mod
|
||||||
|
from app.platform import objectstore as objectstore_mod
|
||||||
|
from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session
|
||||||
|
from app.platform.mailer import Mailer
|
||||||
|
from app.platform import session as session_mod
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
app.state.pool = db.open_pool(dsn, max_size=10)
|
||||||
|
with app.state.pool.connection() as conn:
|
||||||
|
db.migrate(conn) # self-migrate at startup (INV-1, INV-7)
|
||||||
|
app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8
|
||||||
|
app.state.objectstore = objectstore_mod.build_objectstore(config.objectstore_kind())
|
||||||
|
app.state.image_allow_private = config.image_fetch_allow_private()
|
||||||
|
app.state.image_runner = ThreadPoolExecutor(max_workers=2, thread_name_prefix="img-run")
|
||||||
|
# §6.9 startup recovery — resume runs stuck mid image-fetch, off the request path.
|
||||||
|
app.state.image_runner.submit(
|
||||||
|
products.recover_incomplete_runs, app.state.pool,
|
||||||
|
app.state.objectstore, app.state.image_allow_private,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
app.state.image_runner.shutdown(wait=False)
|
||||||
|
app.state.pool.close()
|
||||||
|
|
||||||
|
app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan)
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz(response: Response, conn: psycopg.Connection = Depends(get_conn)):
|
||||||
|
"""Liveness + readiness: process up, DB reachable, migrations current (§6.4)."""
|
||||||
|
try:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
pending = db.pending_migrations(conn)
|
||||||
|
except Exception:
|
||||||
|
response.status_code = 503
|
||||||
|
return {"status": "unavailable", "reason": "database_unreachable"}
|
||||||
|
if pending:
|
||||||
|
response.status_code = 503
|
||||||
|
return {"status": "unavailable", "reason": "migrations_pending"}
|
||||||
|
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.",
|
||||||
|
)
|
||||||
|
app.state.image_runner.submit(
|
||||||
|
products.run_image_phase, app.state.pool, app.state.objectstore,
|
||||||
|
run_id, app.state.image_allow_private,
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=201, content={"run_id": run_id})
|
||||||
|
|
||||||
|
@app.delete("/api/products/imports/drafts/{draft_id}")
|
||||||
|
def discard_import_draft(
|
||||||
|
draft_id: int,
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Discard the draft, no trace kept; idempotent (§6.4; PUC-3a)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
products.discard_draft(conn, sf.id, draft_id)
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@app.get("/api/products/imports/runs")
|
||||||
|
def list_import_runs(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""The storefront's import history, newest first (§6.4; PUC-8)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
return {"runs": products.list_runs(conn, sf.id, limit, offset)}
|
||||||
|
|
||||||
|
@app.get("/api/products/imports/runs/{run_id}")
|
||||||
|
def get_import_run(
|
||||||
|
run_id: int,
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""One run's detail payload, errors included (§6.4; PUC-8)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
try:
|
||||||
|
return products.get_run(conn, sf.id, run_id)
|
||||||
|
except products.RunNotFound:
|
||||||
|
return _error(404, "not_found", "No such import run.")
|
||||||
|
|
||||||
|
@app.get("/api/products/summary")
|
||||||
|
def products_summary(
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""The products dashboard counts (§6.4)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
return products.summary(conn, sf.id)
|
||||||
|
|
||||||
|
@app.get("/api/products/export")
|
||||||
|
def export_products(
|
||||||
|
status: str = Query(default="all", pattern="^(all|active|draft|archived)$"),
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
try:
|
||||||
|
stream = products.export_catalog(conn, sf.id, status)
|
||||||
|
except products.EmptyCatalog:
|
||||||
|
return _error(409, "empty_catalog", "There are no products to export.")
|
||||||
|
return StreamingResponse(
|
||||||
|
stream,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
_RENDITION_CT = {"thumb": "image/webp", "card": "image/webp",
|
||||||
|
"detail": "image/webp", "original": "application/octet-stream"}
|
||||||
|
|
||||||
|
@app.get("/api/products/images/{image_id}/{rendition}")
|
||||||
|
def serve_product_image(
|
||||||
|
image_id: int,
|
||||||
|
rendition: str = ApiPath(pattern="^(original|thumb|card|detail)$"),
|
||||||
|
conn: psycopg.Connection = Depends(get_conn),
|
||||||
|
sess: dict | None = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Serve a hosted image rendition, storefront-authorized + immutable cache (§6.4, INV-16)."""
|
||||||
|
gate = _merchant_gate(conn, sess)
|
||||||
|
if isinstance(gate, JSONResponse):
|
||||||
|
return gate
|
||||||
|
_account, sf = gate
|
||||||
|
rec = products.image_for_serving(conn, sf.id, image_id)
|
||||||
|
if rec is None:
|
||||||
|
return _error(404, "not_found", "No such image.")
|
||||||
|
if rec["status"] != "fetched":
|
||||||
|
return _error(409, "not_fetched", "This image has not been fetched yet.")
|
||||||
|
key = rec[rendition]
|
||||||
|
if not key:
|
||||||
|
return _error(404, "not_found", "No such rendition.")
|
||||||
|
try:
|
||||||
|
data = app.state.objectstore.get(key)
|
||||||
|
except objectstore_mod.ObjectNotFound:
|
||||||
|
return _error(404, "not_found", "No such image.")
|
||||||
|
return Response(content=data, media_type=_RENDITION_CT[rendition],
|
||||||
|
headers={"Cache-Control": "public, max-age=31536000, immutable"})
|
||||||
|
|
||||||
|
@app.get("/api/products/sample.csv")
|
||||||
|
def products_sample_csv():
|
||||||
|
"""The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4)."""
|
||||||
|
return PlainTextResponse(
|
||||||
|
products.SAMPLE_CSV_PATH.read_text(),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""platform layer — cross-cutting primitives (config, db, deps).
|
||||||
|
|
||||||
|
The bottom layer: imports nothing from app.domains or app.main. Owns the schema
|
||||||
|
lifecycle and request wiring, never business rules (SD-0001 §6.2).
|
||||||
|
"""
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Configuration surface — the single place deployment/environment config is read.
|
||||||
|
|
||||||
|
INV-8: no deployment shape is baked into framework code. Every URL, credential, or
|
||||||
|
relay coordinate arrives through here from the environment (resolved from Secret
|
||||||
|
Manager in deployed environments). The localhost defaults match compose.yaml so a
|
||||||
|
clean `scripts/dev.sh` checkout needs no env setup.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Dev default points at the single Postgres container compose.yaml brings up. In
|
||||||
|
# PPE/Prod the deployment supplies ECOMM_DATABASE_URL from Secret Manager (INV-8).
|
||||||
|
_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"}
|
||||||
|
|
||||||
|
|
||||||
|
def objectstore_kind() -> str:
|
||||||
|
"""Which objectstore adapter to build: 'local' (dev/tests) or 'gcs' (deployed)."""
|
||||||
|
return os.environ.get("ECOMM_OBJECTSTORE_KIND") or "local"
|
||||||
|
|
||||||
|
|
||||||
|
def objectstore_bucket() -> str:
|
||||||
|
"""The GCS bucket name for the media objects (gcs adapter; deployment overlay)."""
|
||||||
|
return os.environ.get("ECOMM_OBJECTSTORE_BUCKET", "")
|
||||||
|
|
||||||
|
|
||||||
|
def objectstore_local_dir() -> str:
|
||||||
|
"""Base directory for the local-disk objectstore adapter (dev/tests)."""
|
||||||
|
return os.environ.get("ECOMM_OBJECTSTORE_DIR") or "/tmp/ecomm-objectstore"
|
||||||
|
|
||||||
|
|
||||||
|
def public_base_url() -> str:
|
||||||
|
"""Absolute origin for hosted image URLs in export (e.g. APP_URL); '' → relative."""
|
||||||
|
return (os.environ.get("APP_URL") or "").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def image_fetch_allow_private() -> bool:
|
||||||
|
"""Allow image fetch from private/loopback hosts (dev/E2E fixture host only).
|
||||||
|
Default off — the SSRF guard rejects private ranges in deployed envs."""
|
||||||
|
return os.environ.get("ECOMM_IMAGE_FETCH_ALLOW_PRIVATE", "").strip().lower() in {
|
||||||
|
"1", "true", "yes", "on",
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""PostgreSQL access (psycopg 3) + a forward-only migration runner.
|
||||||
|
|
||||||
|
Standard wiggleverse stack ported to Postgres (SD-0001 D-7): a connection pool and
|
||||||
|
numbered forward-only `.sql` migrations applied in filename order, recorded in
|
||||||
|
`schema_migrations`. No ORM. The runner is fail-stop (a failing migration aborts and
|
||||||
|
records nothing, so it retries next boot) and guarded by a session advisory lock so
|
||||||
|
two booting processes never double-apply (INV-7).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
from psycopg_pool import ConnectionPool
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
# app/platform/db.py -> parents[2] is backend/, where migrations/ lives.
|
||||||
|
MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
|
||||||
|
|
||||||
|
# Arbitrary fixed key for the migration advisory lock (INV-7 concurrency guard).
|
||||||
|
_MIGRATION_LOCK_KEY = 0x6563_6F6D_6D31 # "ecomm1"
|
||||||
|
|
||||||
|
|
||||||
|
def open_pool(dsn: str | None = None, *, min_size: int = 1, max_size: int = 5) -> ConnectionPool:
|
||||||
|
"""Open a small connection pool against the configured database."""
|
||||||
|
pool = ConnectionPool(dsn or config.database_url(), min_size=min_size, max_size=max_size, open=False)
|
||||||
|
pool.open()
|
||||||
|
pool.wait()
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_files(migrations_dir: Path) -> list[Path]:
|
||||||
|
return sorted(migrations_dir.glob("*.sql"))
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||||
|
"""Apply unapplied migrations in filename order; return the names applied.
|
||||||
|
|
||||||
|
Forward-only and fail-stop: each migration runs with its `schema_migrations`
|
||||||
|
insert in one transaction, so a failure records nothing and is retried next time.
|
||||||
|
A session advisory lock serializes concurrent migrators (INV-7).
|
||||||
|
"""
|
||||||
|
conn.execute("SELECT pg_advisory_lock(%s)", (_MIGRATION_LOCK_KEY,))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_migrations ("
|
||||||
|
" filename TEXT PRIMARY KEY,"
|
||||||
|
" applied_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
applied = {
|
||||||
|
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
|
||||||
|
}
|
||||||
|
ran: list[str] = []
|
||||||
|
for path in _migration_files(migrations_dir):
|
||||||
|
if path.name in applied:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn.execute(path.read_text()) # whole file; no params -> multi-statement
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO schema_migrations (filename) VALUES (%s)", (path.name,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
ran.append(path.name)
|
||||||
|
return ran
|
||||||
|
finally:
|
||||||
|
conn.execute("SELECT pg_advisory_unlock(%s)", (_MIGRATION_LOCK_KEY,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def pending_migrations(conn: psycopg.Connection, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]:
|
||||||
|
"""Migration filenames present on disk but not yet recorded as applied.
|
||||||
|
|
||||||
|
Used by /healthz to report migration currency (§6.4). Returns all on-disk
|
||||||
|
migrations if the tracking table does not exist yet.
|
||||||
|
"""
|
||||||
|
exists = conn.execute("SELECT to_regclass('public.schema_migrations')").fetchone()[0]
|
||||||
|
if exists is None:
|
||||||
|
return [p.name for p in _migration_files(migrations_dir)]
|
||||||
|
applied = {
|
||||||
|
r[0] for r in conn.execute("SELECT filename FROM schema_migrations").fetchall()
|
||||||
|
}
|
||||||
|
return [p.name for p in _migration_files(migrations_dir) if p.name not in applied]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""FastAPI dependencies — per-request wiring (SD-0001 §6.2 platform/deps).
|
||||||
|
|
||||||
|
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())
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""platform/images — pure image processing (SD-0002 §6.2).
|
||||||
|
|
||||||
|
Model-free, deterministic, no I/O of its own: decode bytes, enforce the
|
||||||
|
resolution bar (INV-18 / Q-3), and emit downscale-only WebP renditions. The
|
||||||
|
caller (the image-fetch phase) owns fetching, storage, and status.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
# Q-3 (SD-0002 §13): the minimum shorter-side dimension; below it an image is
|
||||||
|
# rejected_low_res. Rejects icons/thumbnails, accepts normal product photos.
|
||||||
|
MIN_IMAGE_SHORT_SIDE = 500
|
||||||
|
|
||||||
|
# Rendition longest-side caps (px); downscale-only — a smaller source is kept
|
||||||
|
# at its own size, never upscaled. Encoded WebP.
|
||||||
|
RENDITIONS = {"thumb": 160, "card": 480, "detail": 1600}
|
||||||
|
|
||||||
|
# Source formats we accept (Pillow format names).
|
||||||
|
_ACCEPTED = {"JPEG", "PNG", "WEBP"}
|
||||||
|
|
||||||
|
_WEBP_QUALITY = 82
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Processed:
|
||||||
|
source_format: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
renditions: dict[str, bytes] # name -> WebP bytes
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Rejected:
|
||||||
|
reason: str # "rejected_low_res" | "rejected_not_image"
|
||||||
|
|
||||||
|
|
||||||
|
def process(data: bytes) -> Processed | Rejected:
|
||||||
|
"""Decode + bar-check + renditions, or a typed rejection. Never raises on
|
||||||
|
bad input — undecodable/unsupported bytes are Rejected('rejected_not_image')."""
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(data)) as im:
|
||||||
|
im.load()
|
||||||
|
source_format = im.format or ""
|
||||||
|
if source_format not in _ACCEPTED:
|
||||||
|
return Rejected(reason="rejected_not_image")
|
||||||
|
rgb = im.convert("RGB")
|
||||||
|
width, height = rgb.size
|
||||||
|
if min(width, height) < MIN_IMAGE_SHORT_SIDE:
|
||||||
|
return Rejected(reason="rejected_low_res")
|
||||||
|
renditions = {
|
||||||
|
name: _rendition(rgb, cap) for name, cap in RENDITIONS.items()
|
||||||
|
}
|
||||||
|
return Processed(
|
||||||
|
source_format=source_format,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
renditions=renditions,
|
||||||
|
)
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError):
|
||||||
|
return Rejected(reason="rejected_not_image")
|
||||||
|
|
||||||
|
|
||||||
|
def _rendition(rgb: Image.Image, cap: int) -> bytes:
|
||||||
|
longest = max(rgb.size)
|
||||||
|
if longest > cap:
|
||||||
|
scale = cap / longest
|
||||||
|
size = (max(1, round(rgb.width * scale)), max(1, round(rgb.height * scale)))
|
||||||
|
resized = rgb.resize(size, Image.LANCZOS)
|
||||||
|
else:
|
||||||
|
resized = rgb
|
||||||
|
buf = io.BytesIO()
|
||||||
|
resized.save(buf, format="WEBP", quality=_WEBP_QUALITY, method=6)
|
||||||
|
return buf.getvalue()
|
||||||
@@ -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}")
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""platform/objectstore — the media-blob port (SD-0002 §6.2, §6.3.1).
|
||||||
|
|
||||||
|
A tiny put/get/delete port with two adapters, mirroring the SD-0001 mailer
|
||||||
|
pattern: local-disk (dev/tests) and GCS (deployed). Owns no semantics — keys
|
||||||
|
and content types come from the caller (the image-fetch phase). Objects are
|
||||||
|
written once, never mutated (immutable, image-id-addressed).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.platform import config
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectNotFound(Exception):
|
||||||
|
"""Raised by get() when the key has no object."""
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStore(Protocol):
|
||||||
|
def put(self, key: str, data: bytes, content_type: str) -> None: ...
|
||||||
|
def get(self, key: str) -> bytes: ...
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def build_objectstore(kind: str) -> ObjectStore:
|
||||||
|
"""Select the objectstore adapter by configured kind (config.objectstore_kind, INV-8)."""
|
||||||
|
if kind == "local":
|
||||||
|
return LocalObjectStore(config.objectstore_local_dir())
|
||||||
|
if kind == "gcs":
|
||||||
|
return GcsObjectStore(config.objectstore_bucket())
|
||||||
|
raise ValueError(f"unknown objectstore kind: {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_segments(key: str) -> tuple[str, ...]:
|
||||||
|
parts = tuple(p for p in key.split("/") if p)
|
||||||
|
if not parts or any(p in {".", ".."} for p in parts):
|
||||||
|
raise ValueError(f"unsafe objectstore key: {key!r}")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
class LocalObjectStore:
|
||||||
|
"""Disk-backed adapter under a base dir; key path-segments become subdirs."""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: str) -> None:
|
||||||
|
self._base = Path(base_dir)
|
||||||
|
|
||||||
|
def _path(self, key: str) -> Path:
|
||||||
|
return self._base.joinpath(*_safe_segments(key))
|
||||||
|
|
||||||
|
def put(self, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
path = self._path(key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(data)
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes:
|
||||||
|
path = self._path(key)
|
||||||
|
try:
|
||||||
|
return path.read_bytes()
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ObjectNotFound(key) from exc
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
try:
|
||||||
|
os.remove(self._path(key))
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GcsObjectStore:
|
||||||
|
"""GCS adapter; auth via the VM service account ADC (no secret bytes).
|
||||||
|
|
||||||
|
google-cloud-storage is imported lazily so dev/test runs (local adapter)
|
||||||
|
don't require the package at import time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, bucket_name: str) -> None:
|
||||||
|
if not bucket_name:
|
||||||
|
raise ValueError("gcs objectstore requires ECOMM_OBJECTSTORE_BUCKET")
|
||||||
|
from google.cloud import storage # lazy
|
||||||
|
|
||||||
|
self._bucket = storage.Client().bucket(bucket_name)
|
||||||
|
|
||||||
|
def put(self, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
_safe_segments(key)
|
||||||
|
self._bucket.blob(key).upload_from_string(data, content_type=content_type)
|
||||||
|
|
||||||
|
def get(self, key: str) -> bytes:
|
||||||
|
from google.cloud.exceptions import NotFound # lazy
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._bucket.blob(key).download_as_bytes()
|
||||||
|
except NotFound as exc:
|
||||||
|
raise ObjectNotFound(key) from exc
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
from google.cloud.exceptions import NotFound # lazy
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bucket.blob(key).delete()
|
||||||
|
except NotFound:
|
||||||
|
pass
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Structured log-event telemetry (SD-0002 §9.1). One JSON object per event on the
|
||||||
|
`ecomm.telemetry` logger — counts and durations only; never file names, URLs,
|
||||||
|
catalog content, or secret bytes (§6.3-handbook)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger("ecomm.telemetry")
|
||||||
|
|
||||||
|
|
||||||
|
def emit(event: str, **fields: object) -> None:
|
||||||
|
_logger.info(json.dumps({"event": event, **fields}, sort_keys=True, default=str))
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- 0001_init.sql — SD-0001 §6.3 data model. Forward-only and fail-stop (INV-7):
|
||||||
|
-- do NOT edit this file once it has merged/applied; add a new numbered migration.
|
||||||
|
|
||||||
|
-- account — a person's identity on the platform. Email is the canonical key (INV-2);
|
||||||
|
-- the address is stored already-normalized (lowercased) by the service layer, so a
|
||||||
|
-- plain unique index enforces one-account-per-email. Data minimization (OHM): email
|
||||||
|
-- and nothing else (§6.3).
|
||||||
|
CREATE TABLE account (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX account_email_key ON account (email); -- INV-2
|
||||||
|
|
||||||
|
-- auth_code — one-time codes, handled like secrets (INV-3): only the hash is stored;
|
||||||
|
-- keyed by email (the code is issued before the account may exist — sign-up and
|
||||||
|
-- log-in converge, PUC-3).
|
||||||
|
CREATE TABLE auth_code (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
code_hash TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX auth_code_email_idx ON auth_code (email);
|
||||||
|
|
||||||
|
-- storefront — a merchant's business presence. Name is always populated (a blank
|
||||||
|
-- entry gets a generated default at the service layer, §6.3); "unnamed" is never NULL.
|
||||||
|
CREATE TABLE storefront (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- storefront_membership — account <-> storefront ownership. INV-4: deliberately a
|
||||||
|
-- many-to-many shape (composite PK, no UNIQUE(account_id)) so many-storefronts-per-
|
||||||
|
-- account stays open; the one-storefront rule is a deletable service-layer guard, not
|
||||||
|
-- a schema law. INV-5: every storefront-scoped row carries its storefront_id. `role`
|
||||||
|
-- is a single-value enum today; it is the seam the staff/permissions model attaches to.
|
||||||
|
CREATE TABLE storefront_membership (
|
||||||
|
account_id BIGINT NOT NULL REFERENCES account (id),
|
||||||
|
storefront_id BIGINT NOT NULL REFERENCES storefront (id),
|
||||||
|
role TEXT NOT NULL DEFAULT 'owner' CHECK (role IN ('owner')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (account_id, storefront_id)
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
addopts = -q
|
||||||
|
# Put backend/ (this rootdir) on sys.path so tests import the `app` package under
|
||||||
|
# pytest's default prepend import mode (tests/ has no __init__.py).
|
||||||
|
pythonpath = .
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.29
|
||||||
|
httpx>=0.27
|
||||||
|
psycopg[binary]>=3.1
|
||||||
|
psycopg-pool>=3.2
|
||||||
|
pytest>=8.0
|
||||||
|
import-linter>=2.0
|
||||||
|
nh3>=0.2
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
Pillow>=11.0
|
||||||
|
google-cloud-storage>=2.18
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Test fixtures — a fresh, empty PostgreSQL database per test.
|
||||||
|
|
||||||
|
Each test gets its own database created on the dev/CI Postgres (every test runs the
|
||||||
|
real engine, §6.8). The admin DSN points at the `postgres` maintenance database;
|
||||||
|
ECOMM_TEST_ADMIN_URL overrides it (CI sets it to the service container).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ADMIN_URL = os.environ.get(
|
||||||
|
"ECOMM_TEST_ADMIN_URL", "postgresql://ecomm:ecomm@localhost:5432/postgres"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _swap_dbname(dsn: str, dbname: str) -> str:
|
||||||
|
# ADMIN_URL ends in `/postgres`; point the same credentials at `dbname`.
|
||||||
|
base, _, _old = dsn.rpartition("/")
|
||||||
|
return f"{base}/{dbname}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fresh_db_url():
|
||||||
|
"""Create a uniquely-named empty database; drop it after the test."""
|
||||||
|
dbname = f"ecomm_test_{uuid.uuid4().hex}"
|
||||||
|
admin = psycopg.connect(ADMIN_URL, autocommit=True)
|
||||||
|
try:
|
||||||
|
admin.execute(f'CREATE DATABASE "{dbname}"')
|
||||||
|
finally:
|
||||||
|
admin.close()
|
||||||
|
try:
|
||||||
|
yield _swap_dbname(ADMIN_URL, dbname)
|
||||||
|
finally:
|
||||||
|
admin = psycopg.connect(ADMIN_URL, autocommit=True)
|
||||||
|
try:
|
||||||
|
admin.execute(
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
||||||
|
"WHERE datname = %s AND pid <> pg_backend_pid()",
|
||||||
|
(dbname,),
|
||||||
|
)
|
||||||
|
admin.execute(f'DROP DATABASE IF EXISTS "{dbname}"')
|
||||||
|
finally:
|
||||||
|
admin.close()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from app.platform import config
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_defaults_to_local_compose(monkeypatch):
|
||||||
|
monkeypatch.delenv("ECOMM_DATABASE_URL", raising=False)
|
||||||
|
assert config.database_url() == "postgresql://ecomm:ecomm@localhost:5432/ecomm"
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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", "version": _VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
def test_startup_migrates_from_empty(fresh_db_url):
|
||||||
|
# create_app on an empty DB must self-migrate so /healthz reports current.
|
||||||
|
app = create_app(database_url=fresh_db_url)
|
||||||
|
with TestClient(app) as client:
|
||||||
|
assert client.get("/healthz").status_code == 200
|
||||||
|
# the schema_migrations row exists -> migrations ran at startup
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
n = conn.execute("SELECT count(*) FROM schema_migrations").fetchone()[0]
|
||||||
|
assert n >= 1
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.platform import db
|
||||||
|
|
||||||
|
_TABLES = {"account", "auth_code", "storefront", "storefront_membership"}
|
||||||
|
|
||||||
|
|
||||||
|
def _table_names(conn) -> set[str]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
|
||||||
|
).fetchall()
|
||||||
|
return {r[0] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
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", "0002_products.sql"]
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
assert _TABLES.issubset(_table_names(conn))
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_is_idempotent(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
applied_again = db.migrate(conn)
|
||||||
|
assert applied_again == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_membership_has_no_unique_account_constraint(fresh_db_url):
|
||||||
|
# INV-4: the schema must keep many-storefronts-per-account open — no UNIQUE on
|
||||||
|
# storefront_membership.account_id anywhere.
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT indexdef FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'storefront_membership'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
defs = " ".join(r[0] for r in rows).lower()
|
||||||
|
assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs
|
||||||
|
|
||||||
|
|
||||||
|
def test_0002_products_tables_exist(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
for table in ("product", "variant", "product_image", "import_draft", "import_run", "import_run_error"):
|
||||||
|
assert conn.execute("SELECT to_regclass(%s)", (f"public.{table}",)).fetchone()[0] == table
|
||||||
|
|
||||||
|
|
||||||
|
def test_0002_variant_option_combo_unique_treats_nulls_as_equal(fresh_db_url):
|
||||||
|
# INV-13: the no-option product's single variant has NULL option values; a second
|
||||||
|
# all-NULL combo must collide (NULLS NOT DISTINCT).
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
sf = conn.execute("INSERT INTO storefront (name) VALUES ('s') RETURNING id").fetchone()[0]
|
||||||
|
pid = conn.execute(
|
||||||
|
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'h','T') RETURNING id", (sf,)
|
||||||
|
).fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 1)", (pid,))
|
||||||
|
with pytest.raises(psycopg.errors.UniqueViolation):
|
||||||
|
conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 2)", (pid,))
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""platform/images — pure decode + resolution bar + WebP renditions (SD-0002 §6.2)."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.platform import images
|
||||||
|
|
||||||
|
|
||||||
|
def _png(width: int, height: int, color=(120, 80, 200)) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (width, height), color).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg(width: int, height: int) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (width, height), (10, 200, 90)).save(buf, format="JPEG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_good_image_produces_three_webp_renditions():
|
||||||
|
result = images.process(_png(1200, 900))
|
||||||
|
assert isinstance(result, images.Processed)
|
||||||
|
assert result.source_format == "PNG"
|
||||||
|
assert set(result.renditions) == {"thumb", "card", "detail"}
|
||||||
|
for name, blob in result.renditions.items():
|
||||||
|
with Image.open(io.BytesIO(blob)) as im:
|
||||||
|
assert im.format == "WEBP"
|
||||||
|
with Image.open(io.BytesIO(result.renditions["thumb"])) as im:
|
||||||
|
assert max(im.size) <= images.RENDITIONS["thumb"]
|
||||||
|
with Image.open(io.BytesIO(result.renditions["detail"])) as im:
|
||||||
|
assert max(im.size) <= images.RENDITIONS["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_downscale_only_never_upscales_small_source():
|
||||||
|
result = images.process(_png(520, 520))
|
||||||
|
with Image.open(io.BytesIO(result.renditions["detail"])) as im:
|
||||||
|
assert im.size == (520, 520)
|
||||||
|
|
||||||
|
|
||||||
|
def test_below_resolution_bar_rejected_low_res():
|
||||||
|
result = images.process(_png(300, 1200)) # shorter side 300 < 500
|
||||||
|
assert isinstance(result, images.Rejected)
|
||||||
|
assert result.reason == "rejected_low_res"
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_an_image_rejected_not_image():
|
||||||
|
result = images.process(b"this is not an image")
|
||||||
|
assert isinstance(result, images.Rejected)
|
||||||
|
assert result.reason == "rejected_not_image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jpeg_source_format_preserved_in_result():
|
||||||
|
result = images.process(_jpeg(800, 800))
|
||||||
|
assert result.source_format == "JPEG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decompression_bomb_rejected_not_image(monkeypatch):
|
||||||
|
# A small file whose pixel count exceeds Pillow's bomb threshold must be a
|
||||||
|
# typed rejection, never an escaping exception.
|
||||||
|
from PIL import Image as PILImage
|
||||||
|
monkeypatch.setattr(PILImage, "MAX_IMAGE_PIXELS", 100) # 10x10 exceeds it
|
||||||
|
result = images.process(_png(800, 800))
|
||||||
|
assert isinstance(result, images.Rejected)
|
||||||
|
assert result.reason == "rejected_not_image"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""platform/objectstore — local-disk adapter round-trip (SD-0002 §6.2/§6.3.1)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.platform import objectstore
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_put_get_round_trip(tmp_path):
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
key = "storefronts/1/product-images/9/original"
|
||||||
|
store.put(key, b"\x89PNG-bytes", "image/png")
|
||||||
|
assert store.get(key) == b"\x89PNG-bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_get_missing_raises_not_found(tmp_path):
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
with pytest.raises(objectstore.ObjectNotFound):
|
||||||
|
store.get("nope/missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_delete_is_idempotent(tmp_path):
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
store.put("a/b", b"x", "text/plain")
|
||||||
|
store.delete("a/b")
|
||||||
|
store.delete("a/b") # no error second time
|
||||||
|
with pytest.raises(objectstore.ObjectNotFound):
|
||||||
|
store.get("a/b")
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_keys_cannot_escape_base_dir(tmp_path):
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
store.put("../escape", b"x", "text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_objectstore_local(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("ECOMM_OBJECTSTORE_KIND", "local")
|
||||||
|
monkeypatch.setenv("ECOMM_OBJECTSTORE_DIR", str(tmp_path))
|
||||||
|
store = objectstore.build_objectstore("local")
|
||||||
|
assert isinstance(store, objectstore.LocalObjectStore)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_objectstore_unknown_kind_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
objectstore.build_objectstore("s3")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""§6.5.1 file-level codec — parse, caps, required columns (PUC-5a fixtures)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domains.products import FileRejected
|
||||||
|
from app.domains.products.codec import parse_csv
|
||||||
|
|
||||||
|
|
||||||
|
def _csv(*lines: str) -> bytes:
|
||||||
|
return ("\n".join(lines) + "\n").encode()
|
||||||
|
|
||||||
|
|
||||||
|
GOOD = _csv(
|
||||||
|
"Handle,Title,Variant Price,Bogus Column",
|
||||||
|
"moon-mug,Moon Mug,18.00,x",
|
||||||
|
"star-tee,Star Tee,24.00,y",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parses_header_rows_and_unknown_columns():
|
||||||
|
parsed = parse_csv(GOOD)
|
||||||
|
assert parsed.dialect == "canonical"
|
||||||
|
assert parsed.unknown_columns == ["Bogus Column"]
|
||||||
|
assert [r.line_number for r in parsed.rows] == [2, 3]
|
||||||
|
assert parsed.rows[0].cells["Handle"] == "moon-mug"
|
||||||
|
assert parsed.rows[0].cells["Variant Price"] == "18.00"
|
||||||
|
assert "Bogus Column" not in parsed.rows[0].cells
|
||||||
|
|
||||||
|
|
||||||
|
def test_bom_tolerated():
|
||||||
|
parsed = parse_csv(b"\xef\xbb\xbf" + GOOD)
|
||||||
|
assert parsed.rows[0].cells["Handle"] == "moon-mug"
|
||||||
|
|
||||||
|
|
||||||
|
def test_quoted_cells_rfc4180():
|
||||||
|
parsed = parse_csv(_csv("Handle,Title,Tags", 'mug,"The ""Best"" Mug","a, b"'))
|
||||||
|
assert parsed.rows[0].cells["Title"] == 'The "Best" Mug'
|
||||||
|
assert parsed.rows[0].cells["Tags"] == "a, b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_rows_skipped_short_rows_padded():
|
||||||
|
parsed = parse_csv(_csv("Handle,Title,Vendor", "mug,Mug", "", ",,", "tee,Tee,Acme"))
|
||||||
|
assert [r.cells["Handle"] for r in parsed.rows] == ["mug", "tee"]
|
||||||
|
assert parsed.rows[0].cells["Vendor"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"data,code",
|
||||||
|
[
|
||||||
|
(b"\xff\xfe\x00garbage\x00", "not_csv"),
|
||||||
|
(b"", "not_csv"),
|
||||||
|
(_csv("Title,Vendor", "Mug,Acme"), "missing_required_column"),
|
||||||
|
(_csv("Handle,Vendor", "mug,Acme"), "missing_required_column"),
|
||||||
|
(
|
||||||
|
_csv("Handle,Title", *(f"h{i},T{i}" for i in range(5001))),
|
||||||
|
"too_many_rows",
|
||||||
|
),
|
||||||
|
(b"Handle,Title\n" + b"x" * (10 * 1024 * 1024), "file_too_large"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_file_level_rejections(data, code):
|
||||||
|
with pytest.raises(FileRejected) as exc:
|
||||||
|
parse_csv(data)
|
||||||
|
assert exc.value.code == code
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_column_message_names_the_column():
|
||||||
|
with pytest.raises(FileRejected) as exc:
|
||||||
|
parse_csv(_csv("Handle,Vendor", "mug,Acme"))
|
||||||
|
assert "'Title'" in exc.value.message
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""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 _catalog_lamp_with_image():
|
||||||
|
# A well-formed single-variant product carrying one fetched image id=55.
|
||||||
|
fields = {
|
||||||
|
"title": "Lamp", "description_html": None, "vendor": "Acme",
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": ["home"], "status": "active", "published": True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"lamp": CatalogProduct(
|
||||||
|
id=1, handle="lamp", title="Lamp",
|
||||||
|
option_names=(None, None, None), fields=fields,
|
||||||
|
variants=[CatalogVariant(id=10, options=(None, None, None), position=1,
|
||||||
|
fields={"sku": "SKU-L", "barcode": None, "price": Decimal("30.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": 5, "variant_image": None})],
|
||||||
|
images=[CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
|
||||||
|
alt_text=None, status="fetched")],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
LAMP_HEADER = "Handle,Title,Vendor,Tags,Status,Variant SKU,Variant Price,Variant Inventory Qty,Image Src,Image Position"
|
||||||
|
LAMP_ROW_PREFIX = "lamp,Lamp,Acme,home,active,SKU-L,30.00,5,"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hosted_url_for_existing_image_is_unchanged_not_add():
|
||||||
|
# Catalog product "lamp" has a fetched image id=55; canonical re-imports it
|
||||||
|
# as the hosted URL -> must be 'unchanged', never 'add'/'update'.
|
||||||
|
diff = compute_diff(
|
||||||
|
_catalog_lamp_with_image(),
|
||||||
|
_canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/55/detail,1"),
|
||||||
|
)
|
||||||
|
kinds = {r["handle"]: r["kind"] for r in diff.records}
|
||||||
|
assert kinds["lamp"] == "unchanged"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hosted_url_for_unknown_id_is_row_error():
|
||||||
|
# Catalog "lamp" has NO images; canonical references /images/999/detail -> error.
|
||||||
|
catalog = _catalog_lamp_with_image()
|
||||||
|
catalog["lamp"].images = []
|
||||||
|
diff = compute_diff(
|
||||||
|
catalog,
|
||||||
|
_canon(LAMP_HEADER, LAMP_ROW_PREFIX + "/api/products/images/999/detail,1"),
|
||||||
|
)
|
||||||
|
kinds = {r["handle"]: r["kind"] for r in diff.records}
|
||||||
|
assert kinds["lamp"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_product_classifies_error():
|
||||||
|
diff = compute_diff({}, _canon("Handle,Title,Variant Price", "mug,Mug,nope"))
|
||||||
|
[rec] = diff.records
|
||||||
|
assert rec["kind"] == "error"
|
||||||
|
assert rec["detail"]["errors"][0]["column"] == "Variant Price"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fingerprint_stable_and_drift_sensitive():
|
||||||
|
d1 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||||
|
d2 = compute_diff(_catalog_mug(), _canon(HEADER, MUG_ROW))
|
||||||
|
d3 = compute_diff(_catalog_mug(title="Renamed"), _canon(HEADER, MUG_ROW))
|
||||||
|
assert d1.fingerprint == d2.fingerprint
|
||||||
|
assert d1.fingerprint != d3.fingerprint
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""§6.4 /api/products/* endpoint scenarios (PUC-2/3/3a/4/5/5a/8 + gates)."""
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\n"
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _merchant_client(fresh_db_url, email="m@example.com"):
|
||||||
|
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||||
|
client.post("/api/auth/request-code", json={"email": email})
|
||||||
|
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||||
|
client.post("/api/auth/verify", json={"email": email, "code": code})
|
||||||
|
client.post("/api/storefronts", json={})
|
||||||
|
yield client
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(client, data=GOOD_CSV, name="cat.csv"):
|
||||||
|
return client.post("/api/products/imports", files={"file": (name, io.BytesIO(data), "text/csv")})
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_returns_201_draft(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
resp = _upload(client)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["summary"]["adds"] == 1 and body["dialect"] == "canonical"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejections_carry_codes(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
resp = _upload(client, b"Vendor\nAcme\n")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["error"]["code"] == "missing_required_column"
|
||||||
|
resp = _upload(client, b"Handle,Title\n" + b"x" * (10 * 1024 * 1024 + 1))
|
||||||
|
assert resp.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_401_and_no_storefront_404(fresh_db_url):
|
||||||
|
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||||
|
assert _upload(client).status_code == 401
|
||||||
|
client.post("/api/auth/request-code", json={"email": "x@example.com"})
|
||||||
|
code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1)
|
||||||
|
client.post("/api/auth/verify", json={"email": "x@example.com", "code": code})
|
||||||
|
assert _upload(client).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_preview_confirm_run_flow(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
draft = _upload(client).json()
|
||||||
|
recs = client.get(f"/api/products/imports/drafts/{draft['id']}/records").json()["records"]
|
||||||
|
assert recs[0]["kind"] == "add"
|
||||||
|
run_id = client.post(f"/api/products/imports/drafts/{draft['id']}/confirm").json()["run_id"]
|
||||||
|
run = client.get(f"/api/products/imports/runs/{run_id}").json()
|
||||||
|
assert run["products_added"] == 1 and run["by"] == "m@example.com"
|
||||||
|
assert client.get("/api/products/summary").json()["product_count"] == 1
|
||||||
|
assert client.get("/api/products/imports/runs").json()["runs"][0]["id"] == run_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_no_trace_puc3a(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
draft = _upload(client).json()
|
||||||
|
assert client.delete(f"/api/products/imports/drafts/{draft['id']}").status_code == 204
|
||||||
|
assert client.get(f"/api/products/imports/drafts/{draft['id']}").status_code == 404
|
||||||
|
assert client.get("/api/products/imports/runs").json()["runs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_conflicts(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
d1 = _upload(client).json()
|
||||||
|
client.post(f"/api/products/imports/drafts/{d1['id']}/confirm")
|
||||||
|
d2 = _upload(client).json()
|
||||||
|
resp = client.post(f"/api/products/imports/drafts/{d2['id']}/confirm")
|
||||||
|
assert resp.status_code == 409 and resp.json()["error"]["code"] == "nothing_to_apply"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_csv_served(fresh_db_url):
|
||||||
|
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||||
|
resp = client.get("/api/products/sample.csv")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("text/csv")
|
||||||
|
assert resp.text.startswith("Handle,Title,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_csv_imports_clean(fresh_db_url):
|
||||||
|
"""DOC-3 honesty: our own sample must validate with zero errors."""
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
sample = client.get("/api/products/sample.csv").content
|
||||||
|
body = _upload(client, sample, "sample.csv").json()
|
||||||
|
assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_returns_canonical_csv(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
draft = _upload(client).json()
|
||||||
|
client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
|
||||||
|
resp = client.get("/api/products/export")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"].startswith("text/csv")
|
||||||
|
assert "attachment" in resp.headers["content-disposition"]
|
||||||
|
body = resp.text
|
||||||
|
assert body.splitlines()[0].startswith("Handle,")
|
||||||
|
assert "moon-mug" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_status_filter_respected(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
# GOOD_CSV's moon-mug has no Status column → defaults to active.
|
||||||
|
draft = _upload(client).json()
|
||||||
|
client.post(f"/api/products/imports/drafts/{draft['id']}/confirm")
|
||||||
|
assert "moon-mug" in client.get("/api/products/export?status=active").text
|
||||||
|
# No archived products → 409.
|
||||||
|
assert client.get("/api/products/export?status=archived").status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_empty_catalog_409(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
resp = client.get("/api/products/export")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.json()["error"]["code"] == "empty_catalog"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_requires_merchant(fresh_db_url):
|
||||||
|
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||||
|
assert client.get("/api/products/export").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_bad_status_422(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
assert client.get("/api/products/export?status=bogus").status_code == 422
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Export: status-filtered catalog snapshot + the streamed service (PUC-9, TEL-3)."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domains import products
|
||||||
|
from app.domains.products import repo
|
||||||
|
from app.platform import db
|
||||||
|
|
||||||
|
CSV = (
|
||||||
|
b"Handle,Title,Vendor,Status,Variant Price\n"
|
||||||
|
b"active-mug,Active Mug,Acme,active,18.00\n"
|
||||||
|
b"draft-tee,Draft Tee,Acme,draft,24.00\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def migrated_conn(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def merchant(migrated_conn):
|
||||||
|
acct = migrated_conn.execute(
|
||||||
|
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||||
|
sf = migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||||
|
migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||||
|
migrated_conn.commit()
|
||||||
|
return {"account_id": acct, "storefront_id": sf}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(conn, merchant):
|
||||||
|
draft = products.import_validate(conn, merchant["storefront_id"], merchant["account_id"], "c.csv", CSV)
|
||||||
|
products.confirm_draft(conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_all_returns_both(migrated_conn, merchant):
|
||||||
|
_seed(migrated_conn, merchant)
|
||||||
|
snap = repo.export_catalog(migrated_conn, merchant["storefront_id"], "all")
|
||||||
|
assert {p.handle for p in snap} == {"active-mug", "draft-tee"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_status_filter(migrated_conn, merchant):
|
||||||
|
_seed(migrated_conn, merchant)
|
||||||
|
active = repo.export_catalog(migrated_conn, merchant["storefront_id"], "active")
|
||||||
|
assert [p.handle for p in active] == ["active-mug"]
|
||||||
|
draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft")
|
||||||
|
assert [p.handle for p in draft] == ["draft-tee"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def telemetry_propagation():
|
||||||
|
"""create_app() sets propagate=False on the parent "ecomm" logger
|
||||||
|
(main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
|
||||||
|
root-logger handler whenever an API test ran first. Restore propagation here."""
|
||||||
|
lg = logging.getLogger("ecomm")
|
||||||
|
prior = lg.propagate
|
||||||
|
lg.propagate = True
|
||||||
|
yield
|
||||||
|
lg.propagate = prior
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_streams_canonical_csv(migrated_conn, merchant):
|
||||||
|
_seed(migrated_conn, merchant)
|
||||||
|
text = "".join(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
|
||||||
|
rows = list(csv.DictReader(io.StringIO(text)))
|
||||||
|
assert {r["Handle"] for r in rows} == {"active-mug", "draft-tee"}
|
||||||
|
assert rows[0]["Handle"] == "active-mug" # sorted by handle
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_empty_raises(migrated_conn, merchant):
|
||||||
|
# No catalog at all → empty.
|
||||||
|
with pytest.raises(products.EmptyCatalog):
|
||||||
|
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_empty_after_filter_raises(migrated_conn, merchant):
|
||||||
|
_seed(migrated_conn, merchant) # only active + draft exist
|
||||||
|
with pytest.raises(products.EmptyCatalog):
|
||||||
|
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "archived"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_tel3_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||||
|
_seed(migrated_conn, merchant)
|
||||||
|
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||||
|
list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all"))
|
||||||
|
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||||
|
exported = [e for e in events if e["event"] == "catalog_exported"]
|
||||||
|
assert len(exported) == 1
|
||||||
|
assert exported[0]["product_count"] == 2
|
||||||
|
assert exported[0]["status_filter"] == "all"
|
||||||
|
assert "duration_ms" in exported[0]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""hosted-image URL helpers — round-trip recognition (SD-0002 §6.3.1, INV-12)."""
|
||||||
|
from app.domains.products import hosted
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_relative_when_no_base():
|
||||||
|
assert hosted.image_url("", 42, "detail") == "/api/products/images/42/detail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_absolute_with_base():
|
||||||
|
url = hosted.image_url("https://ecomm-ppe.wiggleverse.org", 42, "detail")
|
||||||
|
assert url == "https://ecomm-ppe.wiggleverse.org/api/products/images/42/detail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_absolute_hosted_url():
|
||||||
|
url = "https://market.wiggleverse.org/api/products/images/7/detail"
|
||||||
|
assert hosted.parse_image_id(url) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_relative_hosted_url():
|
||||||
|
assert hosted.parse_image_id("/api/products/images/7/card") == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ignores_query_and_trailing():
|
||||||
|
assert hosted.parse_image_id("/api/products/images/7/detail?v=1") == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_non_hosted_returns_none():
|
||||||
|
assert hosted.parse_image_id("https://cdn.example.com/a/b.png") is None
|
||||||
|
assert hosted.parse_image_id("/api/products/images/abc/detail") is None
|
||||||
|
assert hosted.parse_image_id("/api/products/images/7") is None
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""GET /api/products/images/{id}/{rendition} — authorize, stream, immutable cache (§6.4, INV-14/16)."""
|
||||||
|
import psycopg
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import create_app
|
||||||
|
from app.domains import products # noqa: F401 (ensures package import path)
|
||||||
|
|
||||||
|
from test_products_endpoints import _merchant_client # reuse the signed-in client
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_image(fresh_db_url, status="fetched", with_detail_bytes=None, store=None,
|
||||||
|
email_storefront_only=True):
|
||||||
|
"""Insert a product + image for the (only) storefront; set keys; return (storefront_id, image_id, keys)."""
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
sf = conn.execute("SELECT id FROM storefront ORDER BY id LIMIT 1").fetchone()[0]
|
||||||
|
pid = conn.execute(
|
||||||
|
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id",
|
||||||
|
(sf,)).fetchone()[0]
|
||||||
|
iid = conn.execute(
|
||||||
|
"INSERT INTO product_image (product_id, source_url, position, status)"
|
||||||
|
" VALUES (%s,'https://m/a.png',1,%s) RETURNING id", (pid, status)).fetchone()[0]
|
||||||
|
keys = {r: f"storefronts/{sf}/product-images/{iid}/{r}" for r in ("original", "thumb", "card", "detail")}
|
||||||
|
if status == "fetched":
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE product_image SET key_original=%s, key_thumb=%s, key_card=%s, key_detail=%s WHERE id=%s",
|
||||||
|
(keys["original"], keys["thumb"], keys["card"], keys["detail"], iid))
|
||||||
|
conn.commit()
|
||||||
|
if with_detail_bytes is not None and store is not None:
|
||||||
|
store.put(keys["detail"], with_detail_bytes, "image/webp")
|
||||||
|
return sf, iid, keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_serves_fetched_rendition_with_immutable_cache(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
_sf, iid, _keys = _seed_image(fresh_db_url, status="fetched",
|
||||||
|
with_detail_bytes=b"WEBPDATA", store=client.app.state.objectstore)
|
||||||
|
resp = client.get(f"/api/products/images/{iid}/detail")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["content-type"] == "image/webp"
|
||||||
|
assert "immutable" in resp.headers.get("cache-control", "")
|
||||||
|
assert resp.content == b"WEBPDATA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_fetched_returns_409(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
_sf, iid, _keys = _seed_image(fresh_db_url, status="pending")
|
||||||
|
resp = client.get(f"/api/products/images/{iid}/detail")
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.json()["error"]["code"] == "not_fetched"
|
||||||
|
|
||||||
|
|
||||||
|
def test_other_storefronts_image_is_404(fresh_db_url):
|
||||||
|
# Sign in as merchant B first (this migrates the DB), then seed an image
|
||||||
|
# belonging to a *different* storefront A — it must be invisible to B.
|
||||||
|
with _merchant_client(fresh_db_url, email="b@example.com") as client:
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
a = conn.execute("INSERT INTO account (email) VALUES ('a@example.com') RETURNING id").fetchone()[0]
|
||||||
|
sfa = conn.execute("INSERT INTO storefront (name) VALUES ('A') RETURNING id").fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (a, sfa))
|
||||||
|
pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id", (sfa,)).fetchone()[0]
|
||||||
|
iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status) VALUES (%s,'u',1,'fetched') RETURNING id", (pid,)).fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
resp = client.get(f"/api/products/images/{iid}/detail")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_is_401(fresh_db_url):
|
||||||
|
with TestClient(create_app(database_url=fresh_db_url)) as client:
|
||||||
|
assert client.get("/api/products/images/1/detail").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_rendition_is_422(fresh_db_url):
|
||||||
|
with _merchant_client(fresh_db_url) as client:
|
||||||
|
_sf, iid, _keys = _seed_image(fresh_db_url, status="fetched",
|
||||||
|
with_detail_bytes=b"x", store=client.app.state.objectstore)
|
||||||
|
assert client.get(f"/api/products/images/{iid}/huge").status_code == 422
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""image-fetch phase: SSRF guard, fetch+process+store, run completion, resume (§6.5.4/§6.9)."""
|
||||||
|
import io
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app.domains.products import imagefetch, repo
|
||||||
|
from app.platform import db, objectstore
|
||||||
|
|
||||||
|
|
||||||
|
def _png(w, h):
|
||||||
|
buf = io.BytesIO(); Image.new("RGB", (w, h), (1, 2, 3)).save(buf, "PNG"); return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class _Host(BaseHTTPRequestHandler):
|
||||||
|
GOOD = _png(800, 800)
|
||||||
|
TINY = _png(64, 64)
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.startswith("/good.png"):
|
||||||
|
self.send_response(200); self.send_header("Content-Type", "image/png")
|
||||||
|
self.end_headers(); self.wfile.write(self.GOOD)
|
||||||
|
elif self.path.startswith("/tiny.png"):
|
||||||
|
self.send_response(200); self.send_header("Content-Type", "image/png")
|
||||||
|
self.end_headers(); self.wfile.write(self.TINY)
|
||||||
|
else:
|
||||||
|
self.send_response(404); self.end_headers()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def host():
|
||||||
|
srv = HTTPServer(("127.0.0.1", 0), _Host)
|
||||||
|
t = threading.Thread(target=srv.serve_forever, daemon=True); t.start()
|
||||||
|
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
||||||
|
srv.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def pool(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn); conn.commit()
|
||||||
|
p = db.open_pool(fresh_db_url, max_size=6)
|
||||||
|
yield p
|
||||||
|
p.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(pool):
|
||||||
|
"""account+storefront+run; returns (storefront_id, account_id, run_id)."""
|
||||||
|
with pool.connection() as conn:
|
||||||
|
acct = conn.execute("INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||||
|
sf = conn.execute("INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||||
|
rid = conn.execute(
|
||||||
|
"INSERT INTO import_run (storefront_id, account_id, file_name, dialect,"
|
||||||
|
" products_added, products_updated, rows_errored, status)"
|
||||||
|
" VALUES (%s,%s,'c.csv','canonical',0,0,0,'fetching_images') RETURNING id", (sf, acct)).fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return sf, acct, rid
|
||||||
|
|
||||||
|
|
||||||
|
def _product(pool, sf, handle):
|
||||||
|
with pool.connection() as conn:
|
||||||
|
pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id",
|
||||||
|
(sf, handle, handle.title())).fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def _image(pool, pid, url, rid, position=1):
|
||||||
|
with pool.connection() as conn:
|
||||||
|
iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status, import_run_id)"
|
||||||
|
" VALUES (%s,%s,%s,'pending',%s) RETURNING id", (pid, url, position, rid)).fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
return iid
|
||||||
|
|
||||||
|
|
||||||
|
def _img_row(pool, iid):
|
||||||
|
with pool.connection() as conn:
|
||||||
|
r = conn.execute("SELECT status, key_original, key_thumb, key_card, key_detail FROM product_image WHERE id=%s",
|
||||||
|
(iid,)).fetchone()
|
||||||
|
return {"status": r[0], "key_original": r[1], "key_thumb": r[2], "key_card": r[3], "key_detail": r[4]}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_status(pool, rid):
|
||||||
|
with pool.connection() as conn:
|
||||||
|
return conn.execute("SELECT status FROM import_run WHERE id=%s", (rid,)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_guard_rejects_private_when_disallowed():
|
||||||
|
with pytest.raises(imagefetch.FetchBlocked):
|
||||||
|
imagefetch.fetch_bytes("http://127.0.0.1:1/x.png", allow_private=False)
|
||||||
|
with pytest.raises(imagefetch.FetchBlocked):
|
||||||
|
imagefetch.fetch_bytes("http://169.254.169.254/latest/meta-data", allow_private=False)
|
||||||
|
with pytest.raises(imagefetch.FetchBlocked):
|
||||||
|
imagefetch.fetch_bytes("ftp://example.com/x", allow_private=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ssrf_guard_allows_private_when_enabled(host):
|
||||||
|
data, ctype = imagefetch.fetch_bytes(f"{host}/good.png", allow_private=True)
|
||||||
|
assert ctype.startswith("image/") and len(data) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_phase_classifies_each_image(pool, host, tmp_path):
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
sf, _acct, rid = _seed(pool)
|
||||||
|
p = _product(pool, sf, "lamp")
|
||||||
|
ok = _image(pool, p, f"{host}/good.png", rid, position=1)
|
||||||
|
_image(pool, p, f"{host}/tiny.png", rid, position=2)
|
||||||
|
_image(pool, p, f"{host}/missing.png", rid, position=3)
|
||||||
|
imagefetch.run_image_phase(pool, store, rid, allow_private=True)
|
||||||
|
with pool.connection() as conn:
|
||||||
|
counts = repo.run_image_counts(conn, rid)
|
||||||
|
assert counts["fetched"] == 1 and counts["rejected"] == 1 and counts["failed"] == 1
|
||||||
|
row = _img_row(pool, ok)
|
||||||
|
assert row["status"] == "fetched"
|
||||||
|
for k in ("key_original", "key_thumb", "key_card", "key_detail"):
|
||||||
|
assert row[k] and store.get(row[k])
|
||||||
|
assert _run_status(pool, rid) == "complete_with_problems"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unexpected_processing_error_marks_image_failed_not_wedged(pool, host, tmp_path, monkeypatch):
|
||||||
|
# If anything after the fetch raises unexpectedly (e.g. objectstore.put),
|
||||||
|
# the image is marked failed and the run still reaches a terminal status —
|
||||||
|
# never stranded in fetching_images.
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
sf, _acct, rid = _seed(pool)
|
||||||
|
p = _product(pool, sf, "lamp")
|
||||||
|
iid = _image(pool, p, f"{host}/good.png", rid, position=1)
|
||||||
|
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise RuntimeError("storage down")
|
||||||
|
monkeypatch.setattr(store, "put", _boom)
|
||||||
|
|
||||||
|
imagefetch.run_image_phase(pool, store, rid, allow_private=True)
|
||||||
|
with pool.connection() as conn:
|
||||||
|
counts = repo.run_image_counts(conn, rid)
|
||||||
|
assert counts["failed"] == 1 and counts["pending"] == 0
|
||||||
|
assert _run_status(pool, rid) == "complete_with_problems"
|
||||||
|
row = _img_row(pool, iid)
|
||||||
|
assert row["status"] == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_after_kill_completes_remaining(pool, host, tmp_path):
|
||||||
|
store = objectstore.LocalObjectStore(str(tmp_path))
|
||||||
|
sf, _acct, rid = _seed(pool)
|
||||||
|
p = _product(pool, sf, "lamp")
|
||||||
|
a = _image(pool, p, f"{host}/good.png", rid, position=1)
|
||||||
|
with pool.connection() as conn: # simulate crash: one already fetched, run still fetching_images
|
||||||
|
repo.mark_image_fetched(conn, a, {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
|
||||||
|
conn.commit()
|
||||||
|
_image(pool, p, f"{host}/good.png?2", rid, position=2) # still pending
|
||||||
|
resumed = imagefetch.recover_incomplete_runs(pool, store, allow_private=True)
|
||||||
|
assert rid in resumed
|
||||||
|
with pool.connection() as conn:
|
||||||
|
assert repo.run_image_counts(conn, rid)["pending"] == 0
|
||||||
|
assert _run_status(pool, rid) == "complete"
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed),
|
||||||
|
apply transactionality (§6.8), TEL-6."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domains import products
|
||||||
|
from app.domains.products import repo, service
|
||||||
|
from app.platform import db
|
||||||
|
|
||||||
|
CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n"
|
||||||
|
CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def migrated_conn(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
def _merchant(conn, email="m@example.com", shop="Shop"):
|
||||||
|
acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0]
|
||||||
|
sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||||
|
conn.commit()
|
||||||
|
return acct, sf
|
||||||
|
|
||||||
|
|
||||||
|
def _import(conn, acct, sf, data):
|
||||||
|
d = products.import_validate(conn, sf, acct, "f.csv", data)
|
||||||
|
return products.confirm_draft(conn, sf, acct, d["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_inv10_partial_file_never_deletes(migrated_conn):
|
||||||
|
acct, sf = _merchant(migrated_conn)
|
||||||
|
_import(migrated_conn, acct, sf, CSV_A)
|
||||||
|
before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||||
|
_import(migrated_conn, acct, sf, CSV_PARTIAL)
|
||||||
|
after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
|
||||||
|
assert after >= before == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_inv14_two_storefronts_zero_bleed(migrated_conn):
|
||||||
|
acct1, sf1 = _merchant(migrated_conn)
|
||||||
|
acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other")
|
||||||
|
_import(migrated_conn, acct1, sf1, CSV_A)
|
||||||
|
assert products.summary(migrated_conn, sf2)["product_count"] == 0
|
||||||
|
assert products.list_runs(migrated_conn, sf2) == []
|
||||||
|
_import(migrated_conn, acct2, sf2, CSV_A)
|
||||||
|
assert products.summary(migrated_conn, sf2)["product_count"] == 2
|
||||||
|
run1 = products.list_runs(migrated_conn, sf1)[0]
|
||||||
|
with pytest.raises(products.RunNotFound):
|
||||||
|
products.get_run(migrated_conn, sf2, run1["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog):
|
||||||
|
acct, sf = _merchant(migrated_conn)
|
||||||
|
d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A)
|
||||||
|
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("mid-apply crash")
|
||||||
|
monkeypatch.setattr(service.repo, "insert_run_errors", boom)
|
||||||
|
lg = logging.getLogger("ecomm")
|
||||||
|
prior = lg.propagate
|
||||||
|
lg.propagate = True
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
products.confirm_draft(migrated_conn, sf, acct, d["id"])
|
||||||
|
finally:
|
||||||
|
lg.propagate = prior
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1
|
||||||
|
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||||
|
assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""products repo — image-phase helpers (SD-0002 §6.5.4)."""
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domains.products import repo
|
||||||
|
from app.platform import db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def migrated_conn(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def merchant(migrated_conn):
|
||||||
|
acct = migrated_conn.execute(
|
||||||
|
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||||
|
sf = migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||||
|
migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||||
|
migrated_conn.commit()
|
||||||
|
return {"account_id": acct, "storefront_id": sf}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(conn, m, status="fetching_images"):
|
||||||
|
return conn.execute(
|
||||||
|
"INSERT INTO import_run (storefront_id, account_id, file_name, dialect,"
|
||||||
|
" products_added, products_updated, rows_errored, status)"
|
||||||
|
" VALUES (%s,%s,'c.csv','canonical',0,0,0,%s) RETURNING id",
|
||||||
|
(m["storefront_id"], m["account_id"], status)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _product(conn, m, handle):
|
||||||
|
return conn.execute(
|
||||||
|
"INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id",
|
||||||
|
(m["storefront_id"], handle, handle.title())).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _image(conn, product_id, url, run_id, status="pending", position=1):
|
||||||
|
return conn.execute(
|
||||||
|
"INSERT INTO product_image (product_id, source_url, position, status, import_run_id)"
|
||||||
|
" VALUES (%s,%s,%s,%s,%s) RETURNING id",
|
||||||
|
(product_id, url, position, status, run_id)).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_images_for_run_returns_only_pending(migrated_conn, merchant):
|
||||||
|
rid = _run(migrated_conn, merchant)
|
||||||
|
p = _product(migrated_conn, merchant, "lamp")
|
||||||
|
a = _image(migrated_conn, p, "https://m/a.png", rid, status="pending", position=1)
|
||||||
|
_image(migrated_conn, p, "https://m/b.png", rid, status="fetched", position=2)
|
||||||
|
pend = repo.pending_images_for_run(migrated_conn, rid)
|
||||||
|
assert [img["id"] for img in pend] == [a]
|
||||||
|
assert pend[0]["source_url"] == "https://m/a.png"
|
||||||
|
assert pend[0]["storefront_id"] == merchant["storefront_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_image_for_fetch_is_idempotent(migrated_conn, merchant):
|
||||||
|
rid = _run(migrated_conn, merchant)
|
||||||
|
p = _product(migrated_conn, merchant, "lamp")
|
||||||
|
img = _image(migrated_conn, p, "https://m/a.png", rid)
|
||||||
|
assert repo.claim_image_for_fetch(migrated_conn, img) is True
|
||||||
|
repo.mark_image_fetched(migrated_conn, img,
|
||||||
|
{"original": "o", "thumb": "t", "card": "c", "detail": "d"})
|
||||||
|
assert repo.claim_image_for_fetch(migrated_conn, img) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_image_outcomes_and_run_counts(migrated_conn, merchant):
|
||||||
|
rid = _run(migrated_conn, merchant)
|
||||||
|
p = _product(migrated_conn, merchant, "lamp")
|
||||||
|
ok = _image(migrated_conn, p, "https://m/a.png", rid, position=1)
|
||||||
|
bad = _image(migrated_conn, p, "https://m/b.png", rid, position=2)
|
||||||
|
miss = _image(migrated_conn, p, "https://m/c.png", rid, position=3)
|
||||||
|
repo.mark_image_fetched(migrated_conn, ok, {"original": "o", "thumb": "t", "card": "c", "detail": "d"})
|
||||||
|
repo.mark_image_rejected(migrated_conn, bad, "rejected_low_res", "below the resolution bar")
|
||||||
|
repo.mark_image_failed(migrated_conn, miss, "host unreachable")
|
||||||
|
assert repo.run_image_counts(migrated_conn, rid) == {
|
||||||
|
"fetched": 1, "rejected": 1, "failed": 1, "pending": 0, "total": 3}
|
||||||
|
outcomes = repo.run_image_outcomes(migrated_conn, rid)
|
||||||
|
assert {o["handle"] for o in outcomes} == {"lamp"}
|
||||||
|
assert {o["outcome"] for o in outcomes} == {"rejected_low_res", "failed"} # fetched not listed
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_run_status_and_incomplete_runs(migrated_conn, merchant):
|
||||||
|
rid = _run(migrated_conn, merchant, status="fetching_images")
|
||||||
|
_run(migrated_conn, merchant, status="complete")
|
||||||
|
assert rid in [r["id"] for r in repo.incomplete_runs(migrated_conn)]
|
||||||
|
repo.set_run_status(migrated_conn, rid, "complete")
|
||||||
|
assert rid not in [r["id"] for r in repo.incomplete_runs(migrated_conn)]
|
||||||
|
row = migrated_conn.execute("SELECT status, completed_at FROM import_run WHERE id=%s", (rid,)).fetchone()
|
||||||
|
assert row[0] == "complete" and row[1] is not None
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""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"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetched_image_serializes_hosted_detail_url():
|
||||||
|
product = CatalogProduct(
|
||||||
|
id=1, handle="lamp", title="Lamp", option_names=(None, None, None),
|
||||||
|
fields={"title": "Lamp", "status": "active"},
|
||||||
|
variants=[CatalogVariant(id=1, options=(None, None, None), position=1, fields={})],
|
||||||
|
images=[
|
||||||
|
CatalogImage(id=55, source_url="https://m.example/a.png", position=1,
|
||||||
|
alt_text=None, status="fetched"),
|
||||||
|
CatalogImage(id=56, source_url="https://m.example/b.png", position=2,
|
||||||
|
alt_text=None, status="failed"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
rows = list(serialize._product_rows(product, base_url="https://shop.test"))
|
||||||
|
srcs = [r.get("Image Src") for r in rows if r.get("Image Src")]
|
||||||
|
assert "https://shop.test/api/products/images/55/detail" in srcs # fetched -> hosted
|
||||||
|
assert "https://m.example/b.png" in srcs # failed -> source kept
|
||||||
|
|
||||||
|
|
||||||
|
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)):
|
||||||
|
# Mix fetched and non-fetched images: a fetched image exports its
|
||||||
|
# hosted /images/{id}/detail URL, which the diff pre-pass resolves
|
||||||
|
# back to the same id -> still a no-op (INV-12 over hosted images).
|
||||||
|
status = "fetched" if rng.random() < 0.5 else "pending"
|
||||||
|
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}"]),
|
||||||
|
status=status))
|
||||||
|
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, base_url: str = "") -> diff.DiffResult:
|
||||||
|
"""export → bytes → import pipeline → diff against the same catalog."""
|
||||||
|
text = "".join(serialize.catalog_to_csv(catalog.values(), base_url=base_url))
|
||||||
|
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)
|
||||||
|
# A fixed base_url so fetched images export an absolute hosted URL; the
|
||||||
|
# diff resolves it back by id -> the round-trip stays a no-op.
|
||||||
|
result = _roundtrip_diff(catalog, base_url="https://shop.test")
|
||||||
|
assert result.summary["adds"] == 0, f"seed {seed}: {result.summary}"
|
||||||
|
assert result.summary["updates"] == 0, f"seed {seed}: {result.summary}"
|
||||||
|
assert result.summary["errors"] == 0, f"seed {seed}: {result.summary}"
|
||||||
|
assert result.summary["unchanged"] == len(catalog), f"seed {seed}"
|
||||||
|
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
def test_decimal_and_int_variant_fields_roundtrip():
|
||||||
|
catalog = {
|
||||||
|
"priced": CatalogProduct(
|
||||||
|
id=1, handle="priced", title="Priced",
|
||||||
|
option_names=(None, None, None),
|
||||||
|
fields={"title": "Priced", "description_html": None, "vendor": None,
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": [], "status": "active", "published": True},
|
||||||
|
variants=[CatalogVariant(
|
||||||
|
id=1, options=(None, None, None), position=1,
|
||||||
|
fields={"sku": "P1", "price": Decimal("18.00"),
|
||||||
|
"cost": Decimal("9.5"), "weight": Decimal("0.250"),
|
||||||
|
"inventory_qty": 40, "variant_image": None})],
|
||||||
|
images=[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result = _roundtrip_diff(catalog)
|
||||||
|
assert result.summary["unchanged"] == 1, result.records
|
||||||
|
assert result.summary["updates"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonsequential_variant_position_roundtrips():
|
||||||
|
"""A stored variant position that isn't its file order must survive export —
|
||||||
|
else re-import reads the empty cell as 'reset to file order' and the round-trip
|
||||||
|
spuriously updates (INV-12 regression: position is a CatalogVariant attribute,
|
||||||
|
not a fields{} entry, so the serializer must emit it explicitly)."""
|
||||||
|
catalog = {
|
||||||
|
"tee": CatalogProduct(
|
||||||
|
id=1, handle="tee", title="Tee", option_names=("Size", None, None),
|
||||||
|
fields={"title": "Tee", "description_html": None, "vendor": None,
|
||||||
|
"product_type": "standalone", "google_product_category": None,
|
||||||
|
"tags": [], "status": "active", "published": True},
|
||||||
|
variants=[
|
||||||
|
CatalogVariant(id=1, options=("S", None, None), position=10,
|
||||||
|
fields={"sku": "T-S", "variant_image": None}),
|
||||||
|
CatalogVariant(id=2, options=("M", None, None), position=20,
|
||||||
|
fields={"sku": "T-M", "variant_image": None}),
|
||||||
|
],
|
||||||
|
images=[],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result = _roundtrip_diff(catalog)
|
||||||
|
assert result.summary["unchanged"] == 1, result.records
|
||||||
|
assert result.summary["updates"] == 0
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""products service — drafts: validate/preview/discard (PUC-2/3/3a/5a; INV-11)."""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domains import products
|
||||||
|
from app.platform import db
|
||||||
|
|
||||||
|
GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\nstar-tee,Star Tee,Acme,24.00\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def migrated_conn(fresh_db_url):
|
||||||
|
with psycopg.connect(fresh_db_url) as conn:
|
||||||
|
db.migrate(conn)
|
||||||
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def merchant(migrated_conn):
|
||||||
|
acct = migrated_conn.execute(
|
||||||
|
"INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0]
|
||||||
|
sf = migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0]
|
||||||
|
migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
|
||||||
|
migrated_conn.commit()
|
||||||
|
return {"account_id": acct, "storefront_id": sf}
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_validate_creates_draft_with_summary(migrated_conn, merchant):
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
assert draft["dialect"] == "canonical"
|
||||||
|
assert draft["summary"] == {"adds": 2, "updates": 0, "unchanged": 0, "errors": 0}
|
||||||
|
assert draft["expires_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_writes_nothing_to_catalog_inv11(migrated_conn, merchant):
|
||||||
|
products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM variant").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_rejection_leaves_no_draft(migrated_conn, merchant):
|
||||||
|
with pytest.raises(products.FileRejected):
|
||||||
|
products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "bad.csv",
|
||||||
|
b"Vendor,Price\nAcme,1\n")
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_records_paging_and_kind_filter(migrated_conn, merchant):
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
recs = products.get_draft_records(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||||
|
assert [r["handle"] for r in recs] == ["moon-mug", "star-tee"]
|
||||||
|
adds = products.get_draft_records(
|
||||||
|
migrated_conn, merchant["storefront_id"], draft["id"], kind="add", limit=1)
|
||||||
|
assert len(adds) == 1 and adds[0]["kind"] == "add"
|
||||||
|
|
||||||
|
|
||||||
|
def test_discard_deletes_no_trace_puc3a(migrated_conn, merchant):
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||||
|
products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) # idempotent
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_scoped_to_storefront_inv14(migrated_conn, merchant):
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
other_sf = migrated_conn.execute(
|
||||||
|
"INSERT INTO storefront (name) VALUES ('Other') RETURNING id").fetchone()[0]
|
||||||
|
migrated_conn.commit()
|
||||||
|
with pytest.raises(products.DraftNotFound):
|
||||||
|
products.get_draft(migrated_conn, other_sf, draft["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_draft_raises_and_lazily_deletes(migrated_conn, merchant):
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
migrated_conn.execute(
|
||||||
|
"UPDATE import_draft SET expires_at = now() - interval '1 minute' WHERE id = %s",
|
||||||
|
(draft["id"],))
|
||||||
|
migrated_conn.commit()
|
||||||
|
with pytest.raises(products.DraftExpired):
|
||||||
|
products.get_draft(migrated_conn, merchant["storefront_id"], draft["id"])
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def telemetry_propagation():
|
||||||
|
"""create_app() sets propagate=False on the parent "ecomm" logger
|
||||||
|
(main._ensure_app_logging), which hides ecomm.telemetry records from caplog's
|
||||||
|
root-logger handler whenever an API test ran first. Restore propagation here."""
|
||||||
|
lg = logging.getLogger("ecomm")
|
||||||
|
prior = lg.propagate
|
||||||
|
lg.propagate = True
|
||||||
|
yield
|
||||||
|
lg.propagate = prior
|
||||||
|
|
||||||
|
|
||||||
|
def test_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||||
|
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||||
|
products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV)
|
||||||
|
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||||
|
assert any(
|
||||||
|
e["event"] == "import_draft_created" and e["adds"] == 2 and e["row_count"] == 2
|
||||||
|
and "duration_ms" in e and e["unknown_columns_count"] == 0
|
||||||
|
for e in events
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
UPDATE_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,21.00\nstar-tee,Star Tee,Acme,24.00\n"
|
||||||
|
MIXED_CSV = b"Handle,Title,Variant Price\ngood-mug,Mug,10.00\nbad-tee,Tee,not-a-price\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(conn, m, data=GOOD_CSV):
|
||||||
|
return products.import_validate(conn, m["storefront_id"], m["account_id"], "cat.csv", data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_applies_adds_and_records_run(migrated_conn, merchant):
|
||||||
|
draft = _validate(migrated_conn, merchant)
|
||||||
|
run_id = products.confirm_draft(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2
|
||||||
|
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||||
|
assert run["products_added"] == 2 and run["status"] == "complete"
|
||||||
|
assert run["by"] == "m@example.com"
|
||||||
|
assert run["image_progress"] == {"done": 0, "total": 0} and run["image_outcomes"] == []
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_update_changes_only_diffed_fields(migrated_conn, merchant):
|
||||||
|
d1 = _validate(migrated_conn, merchant)
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||||
|
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
|
||||||
|
assert d2["summary"] == {"adds": 0, "updates": 1, "unchanged": 1, "errors": 0}
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||||
|
price = migrated_conn.execute(
|
||||||
|
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert str(price) == "21.00"
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 # no dupes (BUC-3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_blank_position_cell_round_trips(migrated_conn, merchant):
|
||||||
|
# A present-but-empty Variant Position cell resolves to file order at diff
|
||||||
|
# time — never SET position = NULL (which would abort the confirm on the
|
||||||
|
# NOT NULL constraint).
|
||||||
|
d1 = _validate(migrated_conn, merchant)
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||||
|
blank_position_csv = (
|
||||||
|
b"Handle,Title,Vendor,Variant Price,Variant Position\n"
|
||||||
|
b"moon-mug,Moon Mug,Acme,21.00,\n"
|
||||||
|
b"star-tee,Star Tee,Acme,24.00,\n"
|
||||||
|
)
|
||||||
|
d2 = _validate(migrated_conn, merchant, blank_position_csv)
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||||
|
price = migrated_conn.execute(
|
||||||
|
"SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert str(price) == "21.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_mixed_applies_valid_records_errors(migrated_conn, merchant):
|
||||||
|
draft = _validate(migrated_conn, merchant, MIXED_CSV)
|
||||||
|
run_id = products.confirm_draft(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 1
|
||||||
|
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||||
|
assert run["rows_errored"] == 1
|
||||||
|
assert run["errors"][0]["column"] == "Variant Price"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_stale_fingerprint_409_inv11(migrated_conn, merchant):
|
||||||
|
draft = _validate(migrated_conn, merchant)
|
||||||
|
other = _validate(migrated_conn, merchant)
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], other["id"])
|
||||||
|
with pytest.raises(products.PreviewStale):
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_nothing_to_apply_puc10(migrated_conn, merchant):
|
||||||
|
d1 = _validate(migrated_conn, merchant)
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||||
|
d2 = _validate(migrated_conn, merchant)
|
||||||
|
assert d2["summary"]["unchanged"] == 2
|
||||||
|
with pytest.raises(products.NothingToApply):
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_runs_history_newest_first(migrated_conn, merchant):
|
||||||
|
d1 = _validate(migrated_conn, merchant)
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"])
|
||||||
|
d2 = _validate(migrated_conn, merchant, UPDATE_CSV)
|
||||||
|
r2 = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"])
|
||||||
|
runs = products.list_runs(migrated_conn, merchant["storefront_id"])
|
||||||
|
assert [r["id"] for r in runs][0] == r2
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_counts(migrated_conn, merchant):
|
||||||
|
assert products.summary(migrated_conn, merchant["storefront_id"]) == {
|
||||||
|
"product_count": 0, "image_problem_count": 0, "latest_run_id": None}
|
||||||
|
d = _validate(migrated_conn, merchant)
|
||||||
|
rid = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d["id"])
|
||||||
|
s = products.summary(migrated_conn, merchant["storefront_id"])
|
||||||
|
assert s == {"product_count": 2, "image_problem_count": 0, "latest_run_id": rid}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_propagation):
|
||||||
|
draft = _validate(migrated_conn, merchant)
|
||||||
|
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
|
||||||
|
products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
|
||||||
|
assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_marks_run_fetching_images_when_images_present(migrated_conn, merchant):
|
||||||
|
csv = b"Handle,Title,Image Src\nlamp,Lamp,https://m.example/a.png\n"
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
|
||||||
|
run_id = products.confirm_draft(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
run = products.get_run(migrated_conn, merchant["storefront_id"], run_id)
|
||||||
|
assert run["status"] == "fetching_images"
|
||||||
|
assert run["image_progress"]["total"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_marks_run_complete_when_no_images(migrated_conn, merchant):
|
||||||
|
csv = b"Handle,Title,Vendor\nlamp,Lamp,Acme\n"
|
||||||
|
draft = products.import_validate(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv)
|
||||||
|
run_id = products.confirm_draft(
|
||||||
|
migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"])
|
||||||
|
assert products.get_run(migrated_conn, merchant["storefront_id"], run_id)["status"] == "complete"
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""§6.5.1 row validation — every row-error rule has a fixture (SD-0002 §6.8)."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domains.products.codec import parse_csv
|
||||||
|
from app.domains.products.validate import build_products
|
||||||
|
|
||||||
|
|
||||||
|
def _products(*lines: str):
|
||||||
|
return build_products(parse_csv(("\n".join(lines) + "\n").encode()))
|
||||||
|
|
||||||
|
|
||||||
|
def _errors(*lines: str):
|
||||||
|
return [e for p in _products(*lines) for e in p.errors]
|
||||||
|
|
||||||
|
|
||||||
|
def test_simple_product_parses_clean():
|
||||||
|
[p] = _products(
|
||||||
|
"Handle,Title,Vendor,Tags,Status,Published,Variant Price,Variant SKU",
|
||||||
|
"moon-mug,Moon Mug,Acme,\"kitchen, mugs\",active,TRUE,18.00,SKU-1",
|
||||||
|
)
|
||||||
|
assert p.valid and p.handle == "moon-mug" and p.title == "Moon Mug"
|
||||||
|
assert p.fields["tags"] == ["kitchen", "mugs"]
|
||||||
|
assert p.fields["status"] == "active" and p.fields["published"] is True
|
||||||
|
[v] = p.variants
|
||||||
|
assert v.options == (None, None, None)
|
||||||
|
assert v.fields["price"] == Decimal("18.00") and v.fields["sku"] == "SKU-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_option_product_groups_consecutive_rows():
|
||||||
|
[p] = _products(
|
||||||
|
"Handle,Title,Option1 Name,Option1 Value,Variant Price",
|
||||||
|
"tee,Tee,Size,S,24.00",
|
||||||
|
"tee,,,M,24.00",
|
||||||
|
"tee,,,L,26.00",
|
||||||
|
)
|
||||||
|
assert p.valid and p.option_names == ("Size", None, None)
|
||||||
|
assert [v.options[0] for v in p.variants] == ["S", "M", "L"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_only_rows_and_dedupe():
|
||||||
|
[p] = _products(
|
||||||
|
"Handle,Title,Image Src,Image Position,Image Alt Text",
|
||||||
|
"mug,Mug,https://x/a.jpg,1,front",
|
||||||
|
"mug,,https://x/b.jpg,2,back",
|
||||||
|
"mug,,https://x/a.jpg,3,dupe",
|
||||||
|
)
|
||||||
|
assert p.valid
|
||||||
|
assert [(i.source_url, i.position) for i in p.images] == [
|
||||||
|
("https://x/a.jpg", 1), ("https://x/b.jpg", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_variant_image_joins_product_images():
|
||||||
|
[p] = _products(
|
||||||
|
"Handle,Title,Variant Image",
|
||||||
|
"mug,Mug,https://x/v.jpg",
|
||||||
|
)
|
||||||
|
assert p.variants[0].fields["variant_image"] == "https://x/v.jpg"
|
||||||
|
assert [i.source_url for i in p.images] == ["https://x/v.jpg"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_description_sanitized_inv15():
|
||||||
|
[p] = _products(
|
||||||
|
"Handle,Title,Description",
|
||||||
|
'mug,Mug,"<p onclick=\'x()\'>hi</p><script>evil()</script>"',
|
||||||
|
)
|
||||||
|
html = p.fields["description_html"]
|
||||||
|
assert "<p>" in html and "script" not in html and "onclick" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_cell_clears_absent_column_missing():
|
||||||
|
[p] = _products("Handle,Title,Vendor", "mug,Mug,")
|
||||||
|
assert p.fields["vendor"] is None # present-but-empty == clear
|
||||||
|
assert "status" not in p.fields # absent column == untouched
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"header,row,column,fragment",
|
||||||
|
[
|
||||||
|
("Handle,Title", ",NoHandle", "Handle", "needs a Handle"),
|
||||||
|
("Handle,Title", "Bad_Handle!,T", "Handle", "isn't a valid handle"),
|
||||||
|
("Handle,Title", "mug,", "Title", "missing its Title"),
|
||||||
|
("Handle,Title,Type", "mug,Mug,kit_virtual", "Type", "kits arrive"),
|
||||||
|
("Handle,Title,Component 1 SKU", "mug,Mug,ABC", "Component 1 SKU", "kits arrive"),
|
||||||
|
("Handle,Title,Status", "mug,Mug,live", "Status", "is not a status"),
|
||||||
|
("Handle,Title,Published", "mug,Mug,YES", "Published", "is not TRUE or FALSE"),
|
||||||
|
("Handle,Title,Variant Price", 'mug,Mug,"12,50"', "Variant Price", "is not a price"),
|
||||||
|
("Handle,Title,Variant Cost", "mug,Mug,-3", "Variant Cost", "is not a price"),
|
||||||
|
("Handle,Title,Variant Weight", "mug,Mug,heavy", "Variant Weight", "is not a number"),
|
||||||
|
("Handle,Title,Variant Inventory Qty", "mug,Mug,3.5", "Variant Inventory Qty", "is not a whole number"),
|
||||||
|
("Handle,Title,Variant Position", "mug,Mug,0", "Variant Position", "is not a position"),
|
||||||
|
("Handle,Title,Option1 Value", "mug,Mug,Red", "Option1 Value", "no Option1 Name"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_row_error_rules(header, row, column, fragment):
|
||||||
|
errors = _errors(header, row)
|
||||||
|
assert any(e.column == column and fragment in e.message for e in errors), errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_option_value_for_named_option():
|
||||||
|
errors = _errors(
|
||||||
|
"Handle,Title,Option1 Name,Option1 Value,Variant SKU",
|
||||||
|
"tee,Tee,Size,S,A",
|
||||||
|
"tee,,,,B",
|
||||||
|
)
|
||||||
|
assert any("missing its Option1 Value" in e.message and e.line_number == 3 for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_option_combo_is_error():
|
||||||
|
errors = _errors(
|
||||||
|
"Handle,Title,Option1 Name,Option1 Value",
|
||||||
|
"tee,Tee,Size,S",
|
||||||
|
"tee,,,S",
|
||||||
|
)
|
||||||
|
assert any("duplicate variant" in e.message for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_variant_on_no_option_product_is_error():
|
||||||
|
errors = _errors(
|
||||||
|
"Handle,Title,Variant SKU",
|
||||||
|
"mug,Mug,A",
|
||||||
|
"mug,,B",
|
||||||
|
)
|
||||||
|
assert any("without options can have only one variant" in e.message for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_consecutive_handle_is_error():
|
||||||
|
errors = _errors(
|
||||||
|
"Handle,Title",
|
||||||
|
"mug,Mug",
|
||||||
|
"tee,Tee",
|
||||||
|
"mug,",
|
||||||
|
)
|
||||||
|
assert any("must be consecutive" in e.message and e.line_number == 4 for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_data_row_is_error():
|
||||||
|
errors = _errors(
|
||||||
|
"Handle,Title,Variant SKU,Image Src",
|
||||||
|
"mug,Mug,A,",
|
||||||
|
"mug,,,",
|
||||||
|
)
|
||||||
|
assert any("no variant or image data" in e.message for e in errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_errors_poison_their_product_only():
|
||||||
|
products = _products(
|
||||||
|
"Handle,Title,Variant Price",
|
||||||
|
"good-mug,Mug,10.00",
|
||||||
|
"bad-tee,Tee,not-a-price",
|
||||||
|
)
|
||||||
|
by_handle = {p.handle: p for p in products}
|
||||||
|
assert by_handle["good-mug"].valid
|
||||||
|
assert not by_handle["bad-tee"].valid
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_errors_collected_not_first_only():
|
||||||
|
[p] = _products(
|
||||||
|
"Handle,Title,Status,Variant Price",
|
||||||
|
"mug,,bogus,abc",
|
||||||
|
)
|
||||||
|
assert len(p.errors) == 3 # missing Title + bad status + bad price
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Dev datastore for ecomm — a single pinned PostgreSQL service (SD-0001 D-7).
|
||||||
|
# The app runs natively (fast dev loop); only the database is containerized.
|
||||||
|
# `scripts/dev.sh` owns this container's lifecycle. Reset to empty (to rehearse the
|
||||||
|
# bootstrap, BUC-5) with: docker compose down -v
|
||||||
|
name: ecomm-dev
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
# Pinned to the Cloud SQL major version ecomm targets in PPE/Prod (D-7).
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ecomm
|
||||||
|
POSTGRES_PASSWORD: ecomm
|
||||||
|
POSTGRES_DB: ecomm
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- ecomm-pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ecomm -d ecomm"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ecomm-pgdata:
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 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>"
|
||||||
|
ECOMM_OBJECTSTORE_KIND = "gcs"
|
||||||
|
ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media"
|
||||||
|
|
||||||
|
[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"
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Bootstrapping ecomm
|
||||||
|
|
||||||
|
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 shipped the **localhost** section;
|
||||||
|
SLICE-4 adds **pre-production (PPE)**. The production section lands with the prod
|
||||||
|
stand-up.
|
||||||
|
|
||||||
|
## Localhost
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Docker** (Desktop or Engine) with Compose v2 — the one external prerequisite. The
|
||||||
|
dev datastore is a single PostgreSQL container; the app itself runs natively for a
|
||||||
|
fast dev loop (D-7).
|
||||||
|
- **Python 3.13** and **Node 20+** on your PATH. `scripts/dev.sh` creates the backend
|
||||||
|
venv and installs frontend deps on first run.
|
||||||
|
|
||||||
|
### Bring-up
|
||||||
|
|
||||||
|
From a clean checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
|
||||||
|
1. starts the dev Postgres container and waits for it to report healthy (the script
|
||||||
|
owns the container's lifecycle);
|
||||||
|
2. creates the backend virtualenv and installs dependencies (first run only);
|
||||||
|
3. installs the frontend's npm dependencies (first run only);
|
||||||
|
4. runs the FastAPI backend on **:8000** — which connects to the empty database and
|
||||||
|
applies all pending migrations itself (INV-7) — and the Vite dev server on
|
||||||
|
**:5173**, proxying `/api` to the backend.
|
||||||
|
|
||||||
|
Press **Ctrl-C** to stop the backend and Vite. The database container keeps running.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{"status":"ok"}` (HTTP 200) — the process is up, the database is reachable,
|
||||||
|
and migrations are current. Open <http://localhost:5173> to see the app shell.
|
||||||
|
|
||||||
|
### Reset to empty (rehearse the bootstrap)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
This removes the container and its named volume, returning persistence to empty. The
|
||||||
|
next `scripts/dev.sh` re-migrates from scratch — exactly the day-one state every
|
||||||
|
environment starts from (BUC-5a).
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
`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 (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.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# 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` |
|
||||||
|
| TEL-4 `image_phase_completed` | run's fetch task finishes | `run_id, fetched, rejected, failed, duration_ms` |
|
||||||
|
| TEL-5 `image_phase_recovered` | startup recovery resumes a run | `run_id, pending_resumed` |
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Images (SLICE-7)
|
||||||
|
|
||||||
|
After a merchant confirms an import, the app transitions the run to
|
||||||
|
`fetching_images` and processes image URLs via a bounded thread pool
|
||||||
|
(4 workers). Each image goes through the SSRF guard, is decoded, and up to
|
||||||
|
four WebP renditions (`original`, `thumb`, `card`, `detail`) are written to
|
||||||
|
the objectstore under `product-images/`. Progress is tracked per run in
|
||||||
|
`image_progress` / `image_counts` / `image_outcomes` and visible in the
|
||||||
|
import history detail panel (PUC-8). TEL-4 (`image_phase_completed`) fires
|
||||||
|
when the phase finishes; startup recovery emits TEL-5 (`image_phase_recovered`)
|
||||||
|
when it resumes a stuck run.
|
||||||
|
|
||||||
|
#### `provision-bucket` — one-time gesture per environment (SLICE-7 PPE deploy)
|
||||||
|
|
||||||
|
Before the first SLICE-7 deploy, run the `provision-bucket` skill from the
|
||||||
|
engineering `launch-app` suite. This gesture:
|
||||||
|
|
||||||
|
1. Creates the GCS bucket `wiggleverse-ecomm-ppe-media` in the
|
||||||
|
`wiggleverse-ecomm` GCP project.
|
||||||
|
2. Grants the PPE VM's service account `roles/storage.objectAdmin` on the
|
||||||
|
bucket.
|
||||||
|
3. Sets an `import-drafts/` lifecycle rule (forward-compat for the draft-blob
|
||||||
|
migration deferred past SLICE-7).
|
||||||
|
|
||||||
|
After provisioning, `deployment.toml`'s `[overlay]` already carries:
|
||||||
|
|
||||||
|
```
|
||||||
|
ECOMM_OBJECTSTORE_KIND = "gcs"
|
||||||
|
ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media"
|
||||||
|
```
|
||||||
|
|
||||||
|
so the next `flotilla deploy` picks them up automatically. GCS auth is the
|
||||||
|
VM service-account ADC — no credential bytes needed.
|
||||||
|
|
||||||
|
### RB-3 — image phase stuck
|
||||||
|
|
||||||
|
Triggered by ALR-3 (run in `fetching_images` > 2 h, or the same run recovered
|
||||||
|
≥ 3×).
|
||||||
|
|
||||||
|
1. **Identify the stuck run.** The import history (PUC-8, `GET
|
||||||
|
/api/products/imports/runs`) lists runs by status; look for a run with
|
||||||
|
`status="fetching_images"` that hasn't advanced.
|
||||||
|
|
||||||
|
```
|
||||||
|
journalctl -u ecomm.service | grep image_phase
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restart to trigger recovery.** A process restart causes the startup
|
||||||
|
recovery scan to re-enqueue pending images for any run still in
|
||||||
|
`fetching_images` (TEL-5 confirms the resumption). Restarts are safe —
|
||||||
|
per-image claims are idempotent; already-fetched images are skipped.
|
||||||
|
|
||||||
|
```
|
||||||
|
systemctl restart ecomm.service
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **If a specific image URL is the cause.** The `image_outcomes` field on
|
||||||
|
the run records per-image rejection reasons (SSRF block, resolution
|
||||||
|
rejection, network error, etc.). The merchant can update or remove the
|
||||||
|
offending URL and re-import.
|
||||||
|
|
||||||
|
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `run_id` and
|
||||||
|
the `image_outcomes` content from the log.
|
||||||
|
|
||||||
|
### ALR-3 — the log-based alert for stuck image phases
|
||||||
|
|
||||||
|
Run once per environment, at the SLICE-7 PPE deploy. Mirrors ALR-2's
|
||||||
|
approach — a log metric over the stuck/recovered signal, the existing email
|
||||||
|
channel, and an alert policy in project `wiggleverse-ecomm`.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Select the deployment's gcloud config for this one process (handbook §8.4).
|
||||||
|
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
|
||||||
|
|
||||||
|
# Log-based metric counting image-phase events (TEL-4/TEL-5 — stuck/recovered).
|
||||||
|
gcloud logging metrics create ecomm_image_phase_events \
|
||||||
|
--description="ecomm TEL-4/TEL-5 image phase completed/recovered (SD-0002 ALR-3)" \
|
||||||
|
--log-filter='resource.type="gce_instance" AND (jsonPayload.message:"image_phase_completed" OR jsonPayload.message:"image_phase_recovered" OR textPayload:"image_phase_completed" OR textPayload:"image_phase_recovered")'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then attach an alert policy — **operator email channel, threshold any event >
|
||||||
|
0 within a 2-hour window, severity notify-only**. List channels first:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Find the operator email channel's id for the policy.
|
||||||
|
gcloud beta monitoring channels list
|
||||||
|
```
|
||||||
|
|
||||||
|
Create the alert policy (Cloud Console or `gcloud alpha monitoring policies
|
||||||
|
create`); condition: `fetching_images` duration > 2 h **or** the same
|
||||||
|
`run_id` appears in TEL-5 ≥ 3 times.
|
||||||
|
|
||||||
|
### RB-2 — import apply failed
|
||||||
|
|
||||||
|
Triggered by ALR-2 (any TEL-6 event). The apply raised mid-transaction and
|
||||||
|
rolled back.
|
||||||
|
|
||||||
|
1. **Locate the failure.** On the VM, filter the journal for the event and note
|
||||||
|
the `draft_id`, `storefront_id`, and `error_class`:
|
||||||
|
|
||||||
|
```
|
||||||
|
journalctl -u ecomm.service | grep import_apply_failed
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Confirm the rollback held (INV-11).** The apply is one transaction, so a
|
||||||
|
failure leaves the catalog exactly as it was: the storefront's runs history
|
||||||
|
(`GET /api/products/imports/runs`) shows **no new run**, and the products
|
||||||
|
summary (`GET /api/products/summary`) shows an unchanged `product_count`.
|
||||||
|
3. **The merchant's draft is intact.** A failed apply does not consume the
|
||||||
|
draft — the merchant can retry confirm, or re-upload if the draft has since
|
||||||
|
expired (~1 h). Advise accordingly.
|
||||||
|
4. **File a bug** on `wiggleverse/wiggleverse-ecomm` with the `error_class`
|
||||||
|
and the surrounding log context (the traceback is in the app log next to
|
||||||
|
the event).
|
||||||
|
|
||||||
|
### ALR-2 — the log-based alert (one gesture per environment)
|
||||||
|
|
||||||
|
Run once per environment, at this slice's PPE deploy. This is an ad hoc op on
|
||||||
|
the existing GCP project (`wiggleverse-ecomm`), not a provisioning gesture.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Select the deployment's gcloud config for this one process (handbook §8.4).
|
||||||
|
export CLOUDSDK_ACTIVE_CONFIG_NAME=wiggleverse-ecomm
|
||||||
|
|
||||||
|
# Log-based metric counting import-apply failures (TEL-6).
|
||||||
|
gcloud logging metrics create ecomm_import_apply_failed \
|
||||||
|
--description="ecomm TEL-6 import_apply_failed events (SD-0002 ALR-2)" \
|
||||||
|
--log-filter='resource.type="gce_instance" AND jsonPayload.message:"import_apply_failed" OR textPayload:"import_apply_failed"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then attach an alert policy to the metric — **operator email channel, threshold
|
||||||
|
any event > 0 in 5 minutes, severity notify-only** (pre-v1: no paging). The
|
||||||
|
policy is created in the Cloud Console or with
|
||||||
|
`gcloud alpha monitoring policies create`; the exact command depends on the
|
||||||
|
notification-channel id, so list channels first:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Find the operator email channel's id for the policy.
|
||||||
|
gcloud beta monitoring channels list
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E browser suite
|
||||||
|
|
||||||
|
- Lives at `e2e/` — Playwright, Chromium, six scenarios (SLICE-5:
|
||||||
|
preview/confirm happy path, actionable errors, file rejection, cancel;
|
||||||
|
SLICE-6: `e2e_export_download`, `e2e_roundtrip_noop`).
|
||||||
|
- Run with `bash scripts/e2e.sh`. The harness boots a **fresh `ecomm_e2e`
|
||||||
|
database** against the local compose Postgres and serves the built SPA from
|
||||||
|
the backend on **:8765** (the deployed topology), so it needs the dev
|
||||||
|
Postgres up (`scripts/dev.sh`).
|
||||||
|
- **Not in `scripts/check.sh` / CI yet** — the Gitea runner has no browsers
|
||||||
|
(the §10.6 machinery gap). Run it locally before merge, and against PPE per
|
||||||
|
the §9 pipeline (the PPE browser run is still manual this slice).
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
- SLO and alert definitions: SD-0002 §9–§10 (content repo,
|
||||||
|
`wiggleverse-ecomm-content/specs/SD-0002-products-bulk-csv-import-export.md`).
|
||||||
|
- Import/diff engine internals: [`products-domain.md`](./products-domain.md).
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# products domain — developer notes
|
||||||
|
|
||||||
|
DOC-4 (SD-0002 §11): the import/export spine as built in SLICE-5, extended by
|
||||||
|
SLICE-6–8. Operator-facing material is in [`OPERATIONS.md`](./OPERATIONS.md);
|
||||||
|
the spec is SD-0002 in the content repo.
|
||||||
|
|
||||||
|
## Layout and pipeline
|
||||||
|
|
||||||
|
`backend/app/domains/products/` is layered like the rest of the app
|
||||||
|
(main → domains → platform, enforced by import-linter):
|
||||||
|
|
||||||
|
- `models.py` — canonical row model + the column registry.
|
||||||
|
- `codec.py` — bytes → `ParsedFile`; file-level gates only.
|
||||||
|
- `validate.py` — rows → `CanonicalProduct` blocks + per-row errors.
|
||||||
|
- `diff.py` — catalog × canonical products → apply plan + preview records.
|
||||||
|
- `serialize.py` — the export half: `CatalogProduct` snapshot → canonical CSV
|
||||||
|
(the inverse of `codec`/`validate`). DB-free, like `diff.py`.
|
||||||
|
- `repo.py` — SQL only: catalog snapshot, draft/run CRUD, apply primitives.
|
||||||
|
Never commits or rolls back.
|
||||||
|
- `service.py` — use-case orchestration; owns every transaction boundary and
|
||||||
|
emits the TEL events via `backend/app/platform/telemetry.py`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph validate_path [import_validate]
|
||||||
|
A[parse_csv] --> B[build_products] --> D[compute_diff] --> E[(import_draft)]
|
||||||
|
C[load_catalog] --> D
|
||||||
|
end
|
||||||
|
subgraph confirm_path [confirm_draft]
|
||||||
|
E --> F[re-derive: parse → build → diff] --> G{fingerprint match?}
|
||||||
|
G -- yes --> H[one-transaction apply<br/>run + products + delete draft]
|
||||||
|
G -- no --> I[PreviewStale<br/>draft kept]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm re-derives everything from the draft's stored `file_bytes` against the
|
||||||
|
live catalog, then checks the fingerprint — so what lands is exactly what the
|
||||||
|
preview showed, or the confirm refuses (`preview_stale`). A confirm with no
|
||||||
|
adds and no updates refuses with `nothing_to_apply`.
|
||||||
|
|
||||||
|
## Canonical model and blank-vs-absent
|
||||||
|
|
||||||
|
`models.py` is the one model every dialect maps to (INV-17). `KNOWN_COLUMNS`
|
||||||
|
is the registry header detection, unknown-column warnings, and validation all
|
||||||
|
read; `CLEAR_DEFAULTS` holds the reset values for clearable fields
|
||||||
|
(`status`, `published`, `product_type`, `tags`).
|
||||||
|
|
||||||
|
The §6.5.1 cell semantics, as implemented:
|
||||||
|
|
||||||
|
- **Absent column** → the field never enters `fields{}` → untouched by diff
|
||||||
|
and apply (never a change).
|
||||||
|
- **Present-but-empty cell** → `fields[name] = None` (an explicit clear) →
|
||||||
|
resolved at diff time to its `CLEAR_DEFAULTS` entry, or NULL where none
|
||||||
|
exists.
|
||||||
|
- **The position exception:** a cleared `Variant Position` has no
|
||||||
|
`CLEAR_DEFAULTS` entry — `diff.resolved_variant_fields` resolves it to the
|
||||||
|
variant's 1-based file order within its product, never to NULL.
|
||||||
|
|
||||||
|
Option names live both in `CanonicalProduct.option_names` (the values) and in
|
||||||
|
`fields{}` (the file-presence marker the diff needs for the absent-vs-clear
|
||||||
|
distinction).
|
||||||
|
|
||||||
|
## Error granularity
|
||||||
|
|
||||||
|
`validate.py` never raises on a row problem: every violation is recorded as a
|
||||||
|
merchant-language `RowError` and **poisons its whole product block** — the
|
||||||
|
product previews as `kind="error"` and is excluded from apply, while parsing
|
||||||
|
continues so one pass yields a complete accounting (BUC-1a). On apply, error
|
||||||
|
rows are recorded per line in `import_run_error`; `rows_errored` on the run is
|
||||||
|
the **error-row count**, while the preview's errors tile counts error
|
||||||
|
**products** — the two numbers legitimately differ.
|
||||||
|
|
||||||
|
File-level problems (`not_csv`, `missing_required_column`, `too_many_rows`,
|
||||||
|
`file_too_large`) raise `FileRejected` in `codec.py` instead: no draft is
|
||||||
|
created. The BFF adds an early 413 for oversized uploads (`main.py`).
|
||||||
|
|
||||||
|
## INV-11 mechanics
|
||||||
|
|
||||||
|
`compute_diff` makes **one walk** that produces two views of the same
|
||||||
|
computation: the typed apply `plan` (resolved natives — `Decimal`, `bool`,
|
||||||
|
lists) that `confirm_draft` executes, and the JSON-safe preview `records`
|
||||||
|
stored as draft JSONB and served verbatim to the SPA. Because both derive from
|
||||||
|
the same walk they cannot diverge. The `fingerprint` is
|
||||||
|
`sha256(json.dumps(records, sort_keys=True, separators=(",", ":")))`; a
|
||||||
|
mismatch at confirm means the catalog drifted since preview → `PreviewStale`
|
||||||
|
(409 `preview_stale`, draft kept for re-validation). The whole apply — run row,
|
||||||
|
product/variant/image writes, error rows, draft delete — is one transaction;
|
||||||
|
any exception rolls it back and emits TEL-6.
|
||||||
|
|
||||||
|
## Export & the round-trip (SLICE-6)
|
||||||
|
|
||||||
|
`serialize.py` is the export half of "one codec, two directions": it turns the
|
||||||
|
`CatalogProduct` snapshot (the same one `repo.load_catalog` builds for the diff
|
||||||
|
engine) back into canonical CSV, writing exactly the columns `codec.py` /
|
||||||
|
`validate.py` parse, in the §6.5.1 row grammar — `HEADER` is the full canonical
|
||||||
|
column set; product fields + option names sit on the first row; one variant per
|
||||||
|
row; images interleave; a product with more images than variants emits
|
||||||
|
image-only rows.
|
||||||
|
|
||||||
|
`repo.export_catalog` returns the status-filtered snapshot list (sorted by
|
||||||
|
handle, deterministic); the `service.export_catalog` generator streams
|
||||||
|
`serialize.catalog_to_csv` over it and emits TEL-3 (`catalog_exported`) once the
|
||||||
|
stream is exhausted. The BFF wraps it in a `StreamingResponse`; an empty
|
||||||
|
(filtered) catalog raises `EmptyCatalog` **eagerly** → `409 empty_catalog`
|
||||||
|
before any bytes stream.
|
||||||
|
|
||||||
|
**INV-12** (`diff(catalog, import(export(catalog))) = ∅`) is locked two ways: a
|
||||||
|
property test (`test_products_serialize.py`) runs the real
|
||||||
|
export→parse→diff loop over 200 generated **text-field** catalogs and asserts
|
||||||
|
every product is `unchanged`; the `e2e_roundtrip_noop` browser scenario does the
|
||||||
|
same through the UI (export download → re-upload → all-unchanged preview, import
|
||||||
|
disabled). Numeric/`Decimal` fields — the property test's deliberate blind spot
|
||||||
|
(string-form vs value-identity) — get explicit round-trip unit tests.
|
||||||
|
|
||||||
|
## Image pipeline (SLICE-7)
|
||||||
|
|
||||||
|
### `platform/objectstore`
|
||||||
|
|
||||||
|
A two-adapter port (`backend/app/platform/objectstore/`):
|
||||||
|
|
||||||
|
- `local.py` — writes blobs under a configurable local directory; used in
|
||||||
|
tests and local dev (`ECOMM_OBJECTSTORE_KIND=local`).
|
||||||
|
- `gcs.py` — wraps `google-cloud-storage`; bucket name comes from
|
||||||
|
`ECOMM_OBJECTSTORE_BUCKET` (`ECOMM_OBJECTSTORE_KIND=gcs`). Auth is the
|
||||||
|
VM's service-account ADC — no credential bytes in the overlay or secrets.
|
||||||
|
|
||||||
|
Object keys for product images follow the prefix `product-images/` (the
|
||||||
|
`provision-bucket` gesture also sets a lifecycle rule on `import-drafts/` for
|
||||||
|
forward-compat, even though the draft blob remains BYTEA this slice — see
|
||||||
|
seams below).
|
||||||
|
|
||||||
|
### `platform/images`
|
||||||
|
|
||||||
|
`backend/app/platform/images.py` decodes an uploaded or fetched image byte
|
||||||
|
string and produces up to four renditions stored in the objectstore:
|
||||||
|
|
||||||
|
- **`original`** — stored verbatim after the resolution bar passes.
|
||||||
|
- **`thumb`** — 150 px on the shorter side, WebP.
|
||||||
|
- **`card`** — 400 px on the shorter side, WebP.
|
||||||
|
- **`detail`** — 800 px on the shorter side, WebP.
|
||||||
|
|
||||||
|
**Resolution bar (`MIN_IMAGE_SHORT_SIDE = 500`):** images whose shorter side
|
||||||
|
is below 500 px are rejected (Q-3). This is the only hard gate; oversized
|
||||||
|
images are scaled down without rejection. All renditions share the same
|
||||||
|
objectstore key prefix (`product-images/<image_id>/`).
|
||||||
|
|
||||||
|
### `domains/products/imagefetch.py` — the fetch phase
|
||||||
|
|
||||||
|
After `confirm_draft` commits, the run enters `fetching_images` (if it has
|
||||||
|
any pending image URLs) and a `ThreadPoolExecutor(max_workers=4)` processes
|
||||||
|
them concurrently:
|
||||||
|
|
||||||
|
- **SSRF guard (INV-18, extended):** only `http`/`https` schemes are
|
||||||
|
permitted; the resolved IP must be public (RFC-1918, loopback, link-local,
|
||||||
|
and multicast ranges are blocked); redirects are followed only within the
|
||||||
|
same guard — a redirect to a private IP is rejected mid-chain.
|
||||||
|
- **Bounds:** ≤ 20 MB response body, ≤ 30 s per fetch.
|
||||||
|
- **Per-image presence/idempotency check (`claim_image_for_fetch`):** before
|
||||||
|
processing, each `CatalogImage` row is checked so an already-handled row is
|
||||||
|
skipped. This is a presence guard adequate for the **single-worker,
|
||||||
|
in-process** model — the deployed app runs one uvicorn worker, recovery runs
|
||||||
|
at startup over prior-process runs, and a fresh confirm always targets a new
|
||||||
|
run, so two phase invocations never process the same run's images
|
||||||
|
concurrently. It is **not** a cross-process lock. A true multi-worker claim
|
||||||
|
would need a distinct in-flight status transition (`pending → fetching` with
|
||||||
|
`RETURNING`, or `SELECT … FOR UPDATE SKIP LOCKED`) — that is the future seam
|
||||||
|
if the fetch phase ever moves multi-process or onto a queue (§6.9).
|
||||||
|
- **Startup recovery scan:** on process start the app scans for runs stuck in
|
||||||
|
`fetching_images` and re-enqueues their pending images (emits TEL-5). This
|
||||||
|
covers crash/restart scenarios without a separate scheduler. On a mid-fetch
|
||||||
|
process restart (e.g. a deploy) the phase is interrupted and the run is left
|
||||||
|
resumable: pending images stay `pending`, fetched ones stay `fetched`, and
|
||||||
|
the startup scan completes the run. Any pool-closed error in an in-flight
|
||||||
|
worker at shutdown is benign — the uncommitted image stays `pending` and is
|
||||||
|
re-fetched on resume. Mid-flight interruption is tolerated by construction
|
||||||
|
(§6.9).
|
||||||
|
- **Progress tracking:** the run row carries `image_progress`,
|
||||||
|
`image_counts`, and `image_outcomes` (updated per image); these fields feed
|
||||||
|
the run-detail UI panel.
|
||||||
|
|
||||||
|
On completion the phase sets the run status to `complete` and emits TEL-4.
|
||||||
|
|
||||||
|
### Image-serving route
|
||||||
|
|
||||||
|
`GET /api/products/images/{id}/{rendition}` — storefront-authorized,
|
||||||
|
streams the objectstore blob. The response sets
|
||||||
|
`Cache-Control: public, immutable, max-age=31536000` (the object key encodes
|
||||||
|
the image id, so the URL is stable for the lifetime of the image). INV-16:
|
||||||
|
an image belongs to exactly one storefront; the route enforces storefront
|
||||||
|
scope before reading from the objectstore.
|
||||||
|
|
||||||
|
### Hosted-URL recognition and INV-12 over images
|
||||||
|
|
||||||
|
`domains/products/hosted.py` provides a predicate that recognises whether an
|
||||||
|
image URL is already served by this app (i.e., a `GET /api/products/images/…`
|
||||||
|
URL at the current `APP_URL`). The diff pre-pass `_resolve_hosted_images`
|
||||||
|
uses it to convert hosted URLs back to their `CatalogImage` FK before hashing
|
||||||
|
the diff fingerprint — this closes the INV-12 round-trip guarantee over
|
||||||
|
image columns (a re-imported export previews as all-unchanged even when
|
||||||
|
images are hosted here).
|
||||||
|
|
||||||
|
## Named seams (what later slices replace)
|
||||||
|
|
||||||
|
- **`import_draft.file_bytes` remains BYTEA (deferred past SLICE-7).** Moving
|
||||||
|
the draft blob to object storage was scoped out of SLICE-7. The media
|
||||||
|
bucket holds only `product-images/` objects this slice; the `provision-bucket`
|
||||||
|
script sets the `import-drafts/` lifecycle rule for forward-compat so the
|
||||||
|
migration will be clean when it lands.
|
||||||
|
- **`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 → shipped in SLICE-7.**
|
||||||
|
`confirm_draft` now inserts the run as `applying`, then transitions to
|
||||||
|
`fetching_images` (if the run has pending image URLs) or directly to
|
||||||
|
`complete`. The fetch phase fills `image_progress` / `image_counts` /
|
||||||
|
`image_outcomes` and sets `complete` when done.
|
||||||
|
|
||||||
|
## 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
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 10–14)
|
||||||
|
|
||||||
|
- [ ] **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
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.backend.log
|
||||||
|
.objectstore/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty
|
||||||
|
moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40
|
||||||
|
star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12
|
||||||
|
star-tee,,,,,,,M,WG-TEE-M,24.00,18
|
||||||
|
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 B |
@@ -0,0 +1,2 @@
|
|||||||
|
Handle,Vendor
|
||||||
|
mug,Acme
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
Handle,Title,Variant Price
|
||||||
|
good-mug,Good Mug,10.00
|
||||||
|
bad-tee,Bad Tee,not-a-price
|
||||||
|
also-good,Also Good,5.00
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
Handle,Title,Image Src
|
||||||
|
good-lamp,Good Lamp,http://127.0.0.1:8799/good.png
|
||||||
|
tiny-lamp,Tiny Lamp,http://127.0.0.1:8799/tiny.png
|
||||||
|
gone-lamp,Gone Lamp,http://127.0.0.1:8799/missing.png
|
||||||
|
+120
@@ -0,0 +1,120 @@
|
|||||||
|
// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page
|
||||||
|
// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens
|
||||||
|
// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx).
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const LOG = join(__dirname, ".backend.log");
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
// The storefront name every helper-created merchant uses; tests assert against it.
|
||||||
|
export const STOREFRONT_NAME = "E2E Test Goods";
|
||||||
|
|
||||||
|
export function freshEmail(): string {
|
||||||
|
return `merchant${Date.now()}-${seq++}@example.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogMailer's exact line (backend/app/platform/mailer.py):
|
||||||
|
// INFO:ecomm.mailer: LogMailer -> <to> | Your ecomm code: <6 digits>\n<body>
|
||||||
|
// The first 6-digit group after the marker is the code.
|
||||||
|
async function codeFor(email: string): Promise<string> {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const log = await readFile(LOG, "utf8").catch(() => "");
|
||||||
|
const at = log.lastIndexOf(`LogMailer -> ${email}`);
|
||||||
|
if (at >= 0) {
|
||||||
|
const m = log.slice(at).match(/\b(\d{6})\b/);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
throw new Error(`no code logged for ${email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signUpWithStorefront(page: Page, email = freshEmail()): Promise<string> {
|
||||||
|
// Landing → the sign-up door.
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "Create your storefront →" }).click();
|
||||||
|
|
||||||
|
// Sign-in step 1: request the one-time code.
|
||||||
|
await page.getByLabel("Email").fill(email);
|
||||||
|
await page.getByRole("button", { name: "Send code" }).click();
|
||||||
|
|
||||||
|
// Sign-in step 2: read the code from the backend log and verify it.
|
||||||
|
await expect(page.getByRole("heading", { name: "Check your email" })).toBeVisible();
|
||||||
|
const code = await codeFor(email);
|
||||||
|
await page.getByLabel("One-time code").fill(code);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
|
||||||
|
// Create-storefront screen (a new account has none yet).
|
||||||
|
await expect(page.getByRole("heading", { name: "Create your storefront" })).toBeVisible();
|
||||||
|
await page.getByLabel("Storefront name").fill(STOREFRONT_NAME);
|
||||||
|
await page.getByRole("button", { name: "Create storefront", exact: true }).click();
|
||||||
|
|
||||||
|
// Admin shell: nav strip + the storefront identity in the topbar.
|
||||||
|
await expect(page.getByRole("navigation", { name: "Admin sections" })).toBeVisible();
|
||||||
|
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gotoProducts(page: Page) {
|
||||||
|
await page
|
||||||
|
.getByRole("navigation", { name: "Admin sections" })
|
||||||
|
.getByRole("link", { name: "Products" })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFixture(page: Page, fixture: string) {
|
||||||
|
// Two "Import products" links render on the empty products page (header + empty
|
||||||
|
// state) — same destination, so take the first.
|
||||||
|
await page.getByRole("link", { name: "Import products" }).first().click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
|
||||||
|
await page.locator('input[type="file"]').setInputFiles(join(__dirname, "fixtures", fixture));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import good.csv and confirm it, leaving a 2-product catalog. Returns nothing;
|
||||||
|
// callers continue from the run-detail screen.
|
||||||
|
export async function importGoodCsv(page: Page) {
|
||||||
|
await uploadFixture(page, "good.csv");
|
||||||
|
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import with-images.csv (3 products, each with one Image Src) and confirm it.
|
||||||
|
// Mirrors importGoodCsv; callers continue from the run-detail screen, where the
|
||||||
|
// background image fetch progresses fetching_images → terminal.
|
||||||
|
export async function importWithImages(page: Page) {
|
||||||
|
await uploadFixture(page, "with-images.csv");
|
||||||
|
await expect(page.getByRole("heading", { name: "Import preview — with-images.csv" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Import 3 products" }).click();
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "with-images.csv" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click an Export status option and capture the downloaded CSV's text + path.
|
||||||
|
export async function exportCatalog(page: Page, label: string): Promise<{ text: string; path: string }> {
|
||||||
|
// Open the <details> menu, then click the status option, capturing the download.
|
||||||
|
// The menu is a native <details> that *toggles* on each summary click and is
|
||||||
|
// NOT closed by clicking a download <a> (a download doesn't navigate). So on a
|
||||||
|
// second export the menu may already be open — deterministically open it
|
||||||
|
// rather than blind-toggling, and wait for the status link to be visible.
|
||||||
|
const details = page.locator("details.products__export");
|
||||||
|
if (!(await details.evaluate((el: HTMLDetailsElement) => el.open))) {
|
||||||
|
await details.locator("> summary").click();
|
||||||
|
}
|
||||||
|
const link = page.getByRole("link", { name: label, exact: true });
|
||||||
|
await expect(link).toBeVisible();
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
link.click(),
|
||||||
|
]);
|
||||||
|
const stream = await download.createReadStream();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const c of stream) chunks.push(c as Buffer);
|
||||||
|
const text = Buffer.concat(chunks).toString("utf8");
|
||||||
|
const path = join(tmpdir(), `export-${Date.now()}.csv`);
|
||||||
|
await writeFile(path, text, "utf8");
|
||||||
|
return { text, path };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const dir = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "images");
|
||||||
|
createServer(async (req, res) => {
|
||||||
|
const name = (req.url || "").replace(/^\//, "").split("?")[0];
|
||||||
|
if (name === "good.png" || name === "tiny.png") {
|
||||||
|
try {
|
||||||
|
const buf = await readFile(join(dir, name));
|
||||||
|
res.writeHead(200, { "Content-Type": "image/png" });
|
||||||
|
res.end(buf);
|
||||||
|
return;
|
||||||
|
} catch { /* fallthrough */ }
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
}).listen(8799, "127.0.0.1");
|
||||||
Generated
+76
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "wiggleverse-ecomm-e2e",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "wiggleverse-ecomm-e2e",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "wiggleverse-ecomm-e2e",
|
||||||
|
"private": true,
|
||||||
|
"scripts": { "test": "playwright test" },
|
||||||
|
"devDependencies": { "@playwright/test": "^1.48.0" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
timeout: 60_000,
|
||||||
|
retries: 0,
|
||||||
|
// One shared backend + log file; parallel sign-ins would interleave codes.
|
||||||
|
workers: 1,
|
||||||
|
use: { baseURL: "http://localhost:8765" },
|
||||||
|
webServer: {
|
||||||
|
command: "bash ./serve.sh",
|
||||||
|
url: "http://localhost:8765/healthz",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log),
|
||||||
|
# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2).
|
||||||
|
set -euo pipefail
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
if [ ! -f "$repo_root/frontend/dist/index.html" ]; then
|
||||||
|
( cd "$repo_root/frontend" && npm run build )
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$repo_root/.venv/bin/python" - <<'PY'
|
||||||
|
import psycopg
|
||||||
|
admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True)
|
||||||
|
admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()")
|
||||||
|
admin.execute("DROP DATABASE IF EXISTS ecomm_e2e")
|
||||||
|
admin.execute("CREATE DATABASE ecomm_e2e")
|
||||||
|
PY
|
||||||
|
|
||||||
|
export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e"
|
||||||
|
export ECOMM_MAILER=log
|
||||||
|
|
||||||
|
# Image pipeline (SLICE-7): local objectstore + a fixture image host on :8799.
|
||||||
|
# ECOMM_IMAGE_FETCH_ALLOW_PRIVATE lets the SSRF guard reach 127.0.0.1 — TEST-ONLY,
|
||||||
|
# never set in the deployed overlay. The &-backgrounded node host is a child of
|
||||||
|
# this serve process; Playwright tears the whole webServer group down between runs.
|
||||||
|
export ECOMM_OBJECTSTORE_KIND=local
|
||||||
|
export ECOMM_OBJECTSTORE_DIR="$repo_root/e2e/.objectstore"
|
||||||
|
export ECOMM_IMAGE_FETCH_ALLOW_PRIVATE=1
|
||||||
|
rm -rf "$repo_root/e2e/.objectstore"
|
||||||
|
node "$repo_root/e2e/image-host.mjs" &
|
||||||
|
|
||||||
|
cd "$repo_root/backend"
|
||||||
|
exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \
|
||||||
|
> "$repo_root/e2e/.backend.log" 2>&1
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// DoD scenario e2e_export_download (SD-0002 §6.8): export downloads a canonical
|
||||||
|
// CSV and the status filter is respected.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_export_download", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await importGoodCsv(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
|
||||||
|
// Export "All products" → a canonical CSV with both handles.
|
||||||
|
const all = await exportCatalog(page, "All products");
|
||||||
|
expect(all.text.split("\n")[0]).toContain("Handle,");
|
||||||
|
expect(all.text).toContain("moon-mug");
|
||||||
|
expect(all.text).toContain("star-tee");
|
||||||
|
|
||||||
|
// good.csv's products are active → "Active" exports both, "Draft" is empty/disabled-path.
|
||||||
|
const active = await exportCatalog(page, "Active");
|
||||||
|
expect(active.text).toContain("moon-mug");
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, importWithImages, signUpWithStorefront } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_image_outcomes: per-image outcomes, problem rows, notice band", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await importWithImages(page);
|
||||||
|
// Run detail polls fetching_images → terminal; wait for the outcome summary.
|
||||||
|
await expect(page.getByText("1 fetched · 1 rejected · 1 failed")).toBeVisible({ timeout: 30_000 });
|
||||||
|
// Problem images listed in the outcomes table.
|
||||||
|
await expect(page.getByText("tiny-lamp")).toBeVisible();
|
||||||
|
await expect(page.getByText("gone-lamp")).toBeVisible();
|
||||||
|
// Products page surfaces the aggregate notice.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(page.getByText(/products have image problems/)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the
|
||||||
|
// preview gate (PUC-3a) leaves no trace — no products, no run in the history.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_cancel_no_trace", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await uploadFixture(page, "good.csv");
|
||||||
|
|
||||||
|
// The preview gate is up.
|
||||||
|
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Cancel" }).click();
|
||||||
|
|
||||||
|
// Lands back on Products — still the empty state, no run recorded.
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are
|
||||||
|
// actionable — line, column, message — at the preview gate AND on the run report
|
||||||
|
// card (PUC-5), while the good rows still import.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_errors_actionable", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await uploadFixture(page, "mixed-errors.csv");
|
||||||
|
|
||||||
|
// Preview tiles: two good products, one errored row.
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "1 errors" }).click();
|
||||||
|
|
||||||
|
// The error table names the line, the column, and the problem (actionable, PUC-5).
|
||||||
|
const previewRow = page.locator(".errortable tbody tr");
|
||||||
|
await expect(previewRow).toHaveCount(1);
|
||||||
|
await expect(previewRow.locator("td").nth(0)).toHaveText("3");
|
||||||
|
await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||||
|
await expect(previewRow.locator("td").nth(2)).toContainText("is not a price");
|
||||||
|
|
||||||
|
// Good rows still import.
|
||||||
|
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||||
|
|
||||||
|
// Run detail: counts include the errored row, and the same error row persists.
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible();
|
||||||
|
const runRow = page.locator(".errortable tbody tr");
|
||||||
|
await expect(runRow).toHaveCount(1);
|
||||||
|
await expect(runRow.locator("td").nth(0)).toHaveText("3");
|
||||||
|
await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price");
|
||||||
|
await expect(runRow.locator("td").nth(2)).toContainText("is not a price");
|
||||||
|
|
||||||
|
// The catalog gained the two good products.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection
|
||||||
|
// (PUC-5a) renders in place on the upload screen, the picker stays live for a
|
||||||
|
// retry, and nothing is recorded — no products, no run.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_file_rejected", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await uploadFixture(page, "missing-title.csv");
|
||||||
|
|
||||||
|
// The rejection renders in place, naming the missing column.
|
||||||
|
await expect(page.getByText("That file can't be imported")).toBeVisible();
|
||||||
|
await expect(page.getByText("missing the required column 'Title'")).toBeVisible();
|
||||||
|
|
||||||
|
// The picker is live again for a retry.
|
||||||
|
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||||||
|
|
||||||
|
// No trace: still the empty catalog, and no run recorded.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(
|
||||||
|
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("No imports yet.")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to
|
||||||
|
// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm,
|
||||||
|
// run report card, and the catalog + history reflecting the import. Subsumes the
|
||||||
|
// Task-15 harness smoke (sign-up journey + products empty state).
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_import_preview_confirm", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
|
||||||
|
// Admin topbar: storefront identity + the signed-in account chip (ex-smoke).
|
||||||
|
await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME);
|
||||||
|
await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible();
|
||||||
|
|
||||||
|
await gotoProducts(page);
|
||||||
|
|
||||||
|
// Empty state + the import affordances (SD-0002 §5.2, ex-smoke).
|
||||||
|
await expect(
|
||||||
|
page.getByText("No products yet. Bulk import is how product data gets in."),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible();
|
||||||
|
|
||||||
|
await uploadFixture(page, "good.csv");
|
||||||
|
|
||||||
|
// Preview (§5.4): summary tiles + the file's name in the heading.
|
||||||
|
await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible();
|
||||||
|
|
||||||
|
// Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU).
|
||||||
|
const starTee = page.locator(".difflist__item", { hasText: "star-tee" });
|
||||||
|
await starTee.locator("summary").click();
|
||||||
|
await expect(starTee.getByText("WG-TEE-S")).toBeVisible();
|
||||||
|
|
||||||
|
// Consent gate (PUC-3): confirm the import.
|
||||||
|
await page.getByRole("button", { name: "Import 2 products" }).click();
|
||||||
|
|
||||||
|
// Run detail (§5.5): report card with the file name, counts, and status.
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible();
|
||||||
|
await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible();
|
||||||
|
await expect(page.getByText("Complete", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Back on Products: the catalog count and exactly one history row for this run.
|
||||||
|
await gotoProducts(page);
|
||||||
|
await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible();
|
||||||
|
const rows = page.locator(".datatable tbody tr");
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// DoD scenario e2e_roundtrip_noop (SD-0002 §6.8, PUC-10): exporting then
|
||||||
|
// re-importing the unmodified file previews as all-unchanged with the import
|
||||||
|
// action disabled and the "Nothing to change" note.
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers";
|
||||||
|
|
||||||
|
test("e2e_roundtrip_noop", async ({ page }) => {
|
||||||
|
await signUpWithStorefront(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
await importGoodCsv(page);
|
||||||
|
await gotoProducts(page);
|
||||||
|
|
||||||
|
// Export the catalog, then re-import the unmodified file.
|
||||||
|
const { path } = await exportCatalog(page, "All products");
|
||||||
|
await page.getByRole("link", { name: "Import products" }).first().click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible();
|
||||||
|
await page.locator('input[type="file"]').setInputFiles(path);
|
||||||
|
|
||||||
|
// Preview: everything unchanged, nothing to add/update (PUC-10).
|
||||||
|
await expect(page.getByRole("button", { name: "2 unchanged" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "0 to add" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "0 to update" })).toBeVisible();
|
||||||
|
|
||||||
|
// The import action is disabled, with the no-op note.
|
||||||
|
const importBtn = page.getByRole("button", { name: /^Import 0 products$/ });
|
||||||
|
await expect(importBtn).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Nothing to change — your catalog already matches this file"),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0E1230" />
|
||||||
|
<style>html { background: #0E1230; }</style>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/brand/mark-tile.svg" />
|
||||||
|
<title>ecomm</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user