diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4c4a2bc..348986e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,8 +1,8 @@ # Server-side pre-merge gate. Calls the same scripts/check.sh that runs locally, so # the local and server gates can never drift: import-linter (main > domains > -# platform), then backend pytest against a Postgres service, then the frontend -# typecheck + build. Requires a registered Gitea Actions runner advertising the -# `ubuntu-latest` label. +# platform), then backend pytest against a Postgres service. The network-seed reset +# stripped the SPA frontend, so the gate is backend-only now. Requires a registered +# Gitea Actions runner advertising the `ubuntu-latest` label. name: ci on: @@ -35,21 +35,12 @@ jobs: with: python-version: "3.13" - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install backend deps run: | python -m venv .venv .venv/bin/python -m pip install --upgrade pip .venv/bin/python -m pip install -r backend/requirements.txt - - name: Install frontend deps - run: | - cd frontend && npm ci - - name: Run the pre-merge gate env: PYTHON: ${{ github.workspace }}/.venv/bin/python diff --git a/CLAUDE.md b/CLAUDE.md index d485189..bb22f88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,9 +2,12 @@ # wiggleverse-ecomm -The clean implementation of Wiggleverse ecomm — a multi-tenant, OHM-grounded -Shopify alternative. Greenfield: started fresh 2026-06-10, superseding the -first-pass reference prototype (`wiggleverse-ecomm-prototype`). +Wiggleverse ecomm, pivoted to a network-service seed — a minimal, OHM-grounded +FastAPI service for a cross-maker verified referral network. Greenfield: started +fresh 2026-06-10, superseding the first-pass reference prototype +(`wiggleverse-ecomm-prototype`). The network-seed reset (v0.9.0) stripped the +storefront/products HTTP spine and the SPA; the seed is `/healthz` + `/api/auth/*` +(accounts) + the catalog-normalization library (no HTTP surface yet) + `platform/*`. - **One Name:** `ecomm` (see `app.json`). - **Content/corpus repo:** `wiggleverse-ecomm-content` (specs, plans, roadmap, pin). diff --git a/README.md b/README.md index 96feb82..284a5b3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ # wiggleverse-ecomm -The clean implementation of **Wiggleverse ecomm** — a multi-tenant, OHM-grounded -Shopify alternative. +**Wiggleverse ecomm** is being pivoted to a **network-service seed**: a minimal, +OHM-grounded FastAPI service for a cross-maker verified referral network. The +storefront / products import-export spine and the SPA frontend were stripped in the +network-seed reset (v0.9.0); what remains is `/healthz`, the `/api/auth/*` identity +endpoints (the `accounts` domain), the catalog-normalization library kept as +importable modules with no HTTP surface yet, and the generic `platform/*` infra. This repo is greenfield. It was stood up fresh on 2026-06-10, superseding the first-pass **reference prototype** at diff --git a/VERSION b/VERSION index a3df0a6..ac39a10 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.9.0 diff --git a/app.json b/app.json index 95892fd..b8c9311 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "schemaVersion": "1.1", "name": "ecomm", - "title": "Wiggleverse ecomm — multi-tenant commerce", + "title": "Wiggleverse ecomm — cross-maker verified referral network (service)", "giteaHost": "ssh://git@git.wiggleverse.org:2222", "repos": [ { diff --git a/backend/app/domains/__init__.py b/backend/app/domains/__init__.py index ed76d05..540063a 100644 --- a/backend/app/domains/__init__.py +++ b/backend/app/domains/__init__.py @@ -1,6 +1,6 @@ -"""domains layer — bounded contexts (accounts, storefronts). +"""domains layer — bounded contexts (accounts, products). -Empty in SLICE-1; the accounts domain lands in SLICE-2 and storefronts in SLICE-3. -The package exists now so the layer contract (.importlinter) has a target and later -slices add to a stable seam. Domains import only from app.platform, never upward. +The accounts domain is the network-service seed's identity context. The products +package is the catalog-normalization library (no HTTP surface yet). Domains import +only from app.platform, never upward. """ diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py index 0bea1f3..f5bfeb9 100644 --- a/backend/app/domains/products/__init__.py +++ b/backend/app/domains/products/__init__.py @@ -1,14 +1,27 @@ -"""products domain — catalog + bulk CSV import/export (SD-0002 §6.2). +"""products domain — catalog-normalization library (no HTTP surface yet). -Owns the canonical row model, codec, validation, diff engine, and import -drafts/runs. Storefront-scoped throughout (INV-14); upsert is the only mutation -(INV-10). Imported via this package surface only. +The network-seed reset stripped the storefront-scoped import/export/image spine +(service/repo/imagefetch and their endpoints). What remains is the pure, DB-free +catalog-normalization library: the canonical row model, the CSV codec, the Shopify +dialect adapter, validation, the diff engine, and serialization. These submodules are +importable as a library; nothing here owns persistence or an HTTP route. """ from __future__ import annotations from pathlib import Path -from .imagefetch import recover_incomplete_runs, run_image_phase +from .codec import detect_dialect, parse_csv +from .diff import ( + CatalogImage, + CatalogProduct, + CatalogVariant, + DiffResult, + ImagePlan, + ProductPlan, + VariantPlan, + compute_diff, +) +from .dialect_shopify import is_shopify_header, map_shopify_header from .errors import ( DraftExpired, DraftNotFound, @@ -19,30 +32,37 @@ from .errors import ( ProductsError, RunNotFound, ) -from .models import MAX_DATA_ROWS, MAX_FILE_BYTES -from .repo import image_for_serving -from .service import ( - confirm_draft, - discard_draft, - export_catalog, - get_draft, - get_draft_records, - get_run, - import_validate, - list_runs, - summary, +from .models import ( + MAX_DATA_ROWS, + MAX_FILE_BYTES, + CanonicalImage, + CanonicalProduct, + CanonicalVariant, + ParsedFile, + Row, + RowError, ) +from .serialize import catalog_to_csv +from .validate import all_errors, build_products -# DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv. +# DOC-3: the downloadable worked-example CSV. SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv" -# DOC-2: the column reference the BFF serves at /api/products/columns.md. +# DOC-2: the column reference. COLUMNS_MD_PATH = Path(__file__).parent / "columns.md" __all__ = [ + # errors "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog", - "MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", "COLUMNS_MD_PATH", - "import_validate", "get_draft", "get_draft_records", "discard_draft", - "confirm_draft", "list_runs", "get_run", "summary", "export_catalog", - "run_image_phase", "recover_incomplete_runs", "image_for_serving", + # models + limits + "MAX_DATA_ROWS", "MAX_FILE_BYTES", "Row", "RowError", "ParsedFile", + "CanonicalProduct", "CanonicalVariant", "CanonicalImage", + # codec + dialect + "parse_csv", "detect_dialect", "is_shopify_header", "map_shopify_header", + # validate + diff + serialize + "build_products", "all_errors", "compute_diff", "catalog_to_csv", + "CatalogProduct", "CatalogVariant", "CatalogImage", + "ProductPlan", "VariantPlan", "ImagePlan", "DiffResult", + # docs + "SAMPLE_CSV_PATH", "COLUMNS_MD_PATH", ] diff --git a/backend/app/domains/products/imagefetch.py b/backend/app/domains/products/imagefetch.py deleted file mode 100644 index 7b677f5..0000000 --- a/backend/app/domains/products/imagefetch.py +++ /dev/null @@ -1,146 +0,0 @@ -"""The post-commit image-fetch phase (SD-0002 §6.5.4, §6.9). - -In-process, bounded-concurrency, resumable. For a run's pending images: fetch -each URL behind an SSRF guard with INV-18 bounds, run platform/images, store -renditions via the objectstore, and move the image pending -> fetched | -rejected_* | failed. Per-image idempotent (claim guard), so the phase can die -and resume. Lives in the domains layer (imports repo + platform); main.py -schedules it post-confirm and runs recovery at startup. -""" -from __future__ import annotations - -import ipaddress -import socket -import time -from concurrent.futures import ThreadPoolExecutor -from urllib.parse import urlsplit - -import httpx - -from app.platform import images as images_mod -from app.platform import telemetry - -from . import repo - -MAX_FETCH_BYTES = 20 * 1024 * 1024 # INV-18 -FETCH_TIMEOUT_S = 30 # INV-18 -FETCH_CONCURRENCY = 4 # §6.6 -_CONTENT_PREFIX = "image/" - - -class FetchBlocked(Exception): - """SSRF guard or bounds refusal — maps to image status 'failed' with reason.""" - - -def _guard_host(url: str, allow_private: bool) -> None: - parts = urlsplit(url) - if parts.scheme not in {"http", "https"}: - raise FetchBlocked(f"unsupported scheme: {parts.scheme!r}") - host = parts.hostname - if not host: - raise FetchBlocked("no host") - if allow_private: - return - try: - infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80)) - except socket.gaierror as exc: - raise FetchBlocked(f"dns failure: {exc}") from exc - for info in infos: - ip = ipaddress.ip_address(info[4][0]) - if (ip.is_private or ip.is_loopback or ip.is_link_local - or ip.is_reserved or ip.is_multicast or ip.is_unspecified): - raise FetchBlocked(f"non-public address: {ip}") - - -def fetch_bytes(url: str, allow_private: bool) -> tuple[bytes, str]: - """SSRF-guarded, bounded GET. Returns (bytes, content_type). Raises FetchBlocked.""" - _guard_host(url, allow_private) - with httpx.Client(follow_redirects=False, timeout=FETCH_TIMEOUT_S) as client: - with client.stream("GET", url) as resp: - if resp.status_code != 200: - raise FetchBlocked(f"http {resp.status_code}") - ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower() - if not ctype.startswith(_CONTENT_PREFIX): - raise FetchBlocked(f"not an image content-type: {ctype!r}") - chunks: list[bytes] = [] - total = 0 - for chunk in resp.iter_bytes(): - total += len(chunk) - if total > MAX_FETCH_BYTES: - raise FetchBlocked("oversize") - chunks.append(chunk) - return b"".join(chunks), ctype - - -def _key(storefront_id: int, image_id: int, name: str) -> str: - return f"storefronts/{storefront_id}/product-images/{image_id}/{name}" - - -def _process_one(pool, store, image: dict, allow_private: bool) -> None: - with pool.connection() as conn: - claimed = repo.claim_image_for_fetch(conn, image["id"]) - conn.commit() - if not claimed: - return - image_id, sf = image["id"], image["storefront_id"] - try: - data, ctype = fetch_bytes(image["source_url"], allow_private) - except (FetchBlocked, httpx.HTTPError) as exc: - with pool.connection() as conn: - repo.mark_image_failed(conn, image_id, str(exc)[:200]); conn.commit() - return - try: - result = images_mod.process(data) - if isinstance(result, images_mod.Rejected): - reason = ("below the resolution bar" if result.reason == "rejected_low_res" - else "not a supported image") - with pool.connection() as conn: - repo.mark_image_rejected(conn, image_id, result.reason, reason); conn.commit() - return - keys = {"original": _key(sf, image_id, "original"), - "thumb": _key(sf, image_id, "thumb.webp"), - "card": _key(sf, image_id, "card.webp"), - "detail": _key(sf, image_id, "detail.webp")} - store.put(keys["original"], data, ctype) - store.put(keys["thumb"], result.renditions["thumb"], "image/webp") - store.put(keys["card"], result.renditions["card"], "image/webp") - store.put(keys["detail"], result.renditions["detail"], "image/webp") - with pool.connection() as conn: - repo.mark_image_fetched(conn, image_id, keys); conn.commit() - except Exception as exc: # noqa: BLE001 — one bad image must never wedge the run (review fix) - with pool.connection() as conn: - repo.mark_image_failed(conn, image_id, f"processing error: {type(exc).__name__}") - conn.commit() - - -def run_image_phase(pool, store, run_id: int, allow_private: bool) -> None: - """Fetch all pending images of one run, then move the run to a terminal status.""" - started = time.monotonic() - with pool.connection() as conn: - pending = repo.pending_images_for_run(conn, run_id) - if pending: - with ThreadPoolExecutor(max_workers=FETCH_CONCURRENCY) as pool_x: - list(pool_x.map(lambda img: _process_one(pool, store, img, allow_private), pending)) - with pool.connection() as conn: - counts = repo.run_image_counts(conn, run_id) - status = ("complete_with_problems" - if counts["rejected"] + counts["failed"] > 0 else "complete") - repo.set_run_status(conn, run_id, status) - conn.commit() - telemetry.emit("image_phase_completed", run_id=run_id, fetched=counts["fetched"], - rejected=counts["rejected"], failed=counts["failed"], - duration_ms=int((time.monotonic() - started) * 1000)) - - -def recover_incomplete_runs(pool, store, allow_private: bool) -> list[int]: - """Startup scan (§6.9): resume any run stuck in fetching_images. Returns the ids.""" - with pool.connection() as conn: - runs = repo.incomplete_runs(conn) - resumed: list[int] = [] - for run in runs: - with pool.connection() as conn: - pending = len(repo.pending_images_for_run(conn, run["id"])) - telemetry.emit("image_phase_recovered", run_id=run["id"], pending_resumed=pending) - run_image_phase(pool, store, run["id"], allow_private) - resumed.append(run["id"]) - return resumed diff --git a/backend/app/domains/products/repo.py b/backend/app/domains/products/repo.py deleted file mode 100644 index 04bef57..0000000 --- a/backend/app/domains/products/repo.py +++ /dev/null @@ -1,616 +0,0 @@ -"""products repo — the SQL layer for the import spine (SD-0002 §6.3 data model). - -Owns SQL only: the catalog snapshot the diff engine reads, import draft/run -CRUD, and the apply primitives the confirm transaction calls. Business rules -live in service.py and diff.py — nothing here validates, diffs, commits, or -rolls back (the confirm flow runs the apply primitives inside its own -transaction). Every catalog/draft/run query is storefront-scoped (INV-14). - -Dict payload conventions: functions feeding §6.4 API payloads (insert_draft, -list_runs, get_run) return datetimes as `.isoformat()` strings; get_draft_row -returns raw datetimes for the service's expiry check. TEXT[] columns bind/load -as Python lists and NUMERIC loads as Decimal natively under psycopg 3. -""" -from __future__ import annotations - -import psycopg -from psycopg import sql -from psycopg.types.json import Jsonb - -from .diff import CatalogImage, CatalogProduct, CatalogVariant - -# --------------------------------------------------------------------------- -# Catalog snapshot (diff input) + dashboard counts -# --------------------------------------------------------------------------- - - -def load_catalog(conn: psycopg.Connection, storefront_id: int) -> dict[str, CatalogProduct]: - """The storefront's full catalog, keyed by handle, in diff.py's snapshot shape.""" - catalog: dict[str, CatalogProduct] = {} - by_id: dict[int, CatalogProduct] = {} - for row in conn.execute( - "SELECT id, handle, title, description_html, vendor, product_type," - " google_product_category, tags, status, published," - " option1_name, option2_name, option3_name" - " FROM product WHERE storefront_id = %s", - (storefront_id,), - ): - product = CatalogProduct( - id=row[0], - handle=row[1], - title=row[2], - option_names=(row[10], row[11], row[12]), - fields={ - "title": row[2], - "description_html": row[3], - "vendor": row[4], - "product_type": row[5], - "google_product_category": row[6], - "tags": row[7], - "status": row[8], - "published": row[9], - }, - variants=[], - images=[], - ) - catalog[product.handle] = product - by_id[product.id] = product - for row in conn.execute( - "SELECT v.product_id, v.id, v.position," - " v.option1_value, v.option2_value, v.option3_value," - " v.sku, v.barcode, v.price, v.cost, v.weight, v.weight_unit," - " v.volume, v.volume_unit, v.tax_id_1, v.tax_id_2," - " v.inventory_tracker, v.inventory_qty, i.source_url" - " FROM variant v" - " JOIN product p ON p.id = v.product_id" - " LEFT JOIN product_image i ON i.id = v.image_id" - " WHERE p.storefront_id = %s" - " ORDER BY v.product_id, v.position, v.id", - (storefront_id,), - ): - by_id[row[0]].variants.append( - CatalogVariant( - id=row[1], - options=(row[3], row[4], row[5]), - position=row[2], - fields={ - "sku": row[6], - "barcode": row[7], - "price": row[8], - "cost": row[9], - "weight": row[10], - "weight_unit": row[11], - "volume": row[12], - "volume_unit": row[13], - "tax_id_1": row[14], - "tax_id_2": row[15], - "inventory_tracker": row[16], - "inventory_qty": row[17], - "variant_image": row[18], - }, - ) - ) - for row in conn.execute( - "SELECT i.product_id, i.id, i.source_url, i.position, i.alt_text, i.status" - " FROM product_image i" - " JOIN product p ON p.id = i.product_id" - " WHERE p.storefront_id = %s" - " ORDER BY i.product_id, i.position, i.id", - (storefront_id,), - ): - by_id[row[0]].images.append( - CatalogImage(id=row[1], source_url=row[2], position=row[3], - alt_text=row[4], status=row[5]) - ) - return catalog - - -_EXPORT_STATUSES = ("all", "active", "draft", "archived") - - -def export_catalog( - conn: psycopg.Connection, storefront_id: int, status_filter: str -) -> list[CatalogProduct]: - """The storefront's catalog as an ordered snapshot list, optionally filtered - by product status (PUC-9). Reuses load_catalog's snapshot builder; the - catalog fits in memory (≤5k rows, INV-18). Ordered by handle for a stable, - deterministic export.""" - catalog = load_catalog(conn, storefront_id) - products = sorted(catalog.values(), key=lambda p: p.handle) - if status_filter and status_filter != "all": - products = [p for p in products if p.fields.get("status") == status_filter] - return products - - -def product_count(conn: psycopg.Connection, storefront_id: int) -> int: - return conn.execute( - "SELECT count(*) FROM product WHERE storefront_id = %s", (storefront_id,) - ).fetchone()[0] - - -def image_problem_count(conn: psycopg.Connection, storefront_id: int) -> int: - return conn.execute( - "SELECT count(*) FROM product_image i" - " JOIN product p ON p.id = i.product_id" - " WHERE p.storefront_id = %s" - " AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')", - (storefront_id,), - ).fetchone()[0] - - -def latest_run_id(conn: psycopg.Connection, storefront_id: int) -> int | None: - row = conn.execute( - "SELECT id FROM import_run WHERE storefront_id = %s" - " ORDER BY created_at DESC, id DESC LIMIT 1", - (storefront_id,), - ).fetchone() - return row[0] if row else None - - -# --------------------------------------------------------------------------- -# Import drafts (preview server side, INV-11) -# --------------------------------------------------------------------------- - - -def insert_draft( - conn: psycopg.Connection, - storefront_id: int, - account_id: int, - file_name: str, - dialect: str, - file_bytes: bytes, - summary: dict, - records: list, - fingerprint: str, - unknown_columns: list[str], -) -> dict: - """Create a draft (expires in 1 hour); returns the §6.4 draft payload.""" - row = conn.execute( - "INSERT INTO import_draft" - " (storefront_id, account_id, file_name, dialect, file_bytes," - " summary, records, fingerprint, unknown_columns, expires_at)" - " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, now() + interval '1 hour')" - " RETURNING id, expires_at", - ( - storefront_id, - account_id, - file_name, - dialect, - file_bytes, - Jsonb(summary), - Jsonb(records), - fingerprint, - unknown_columns, - ), - ).fetchone() - return { - "id": row[0], - "file_name": file_name, - "dialect": dialect, - "summary": summary, - "unknown_columns": unknown_columns, - "expires_at": row[1].isoformat(), - } - - -def get_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict | None: - row = conn.execute( - "SELECT id, storefront_id, account_id, file_name, dialect, file_bytes," - " summary, records, fingerprint, unknown_columns, expires_at, created_at" - " FROM import_draft WHERE id = %s AND storefront_id = %s", - (draft_id, storefront_id), - ).fetchone() - if row is None: - return None - columns = ( - "id", - "storefront_id", - "account_id", - "file_name", - "dialect", - "file_bytes", - "summary", - "records", - "fingerprint", - "unknown_columns", - "expires_at", - "created_at", - ) - record = dict(zip(columns, row)) - # BYTEA loads as memoryview; the service expects bytes. - record["file_bytes"] = bytes(record["file_bytes"]) - return record - - -def draft_records( - conn: psycopg.Connection, - storefront_id: int, - draft_id: int, - kind: str | None, - limit: int, - offset: int, -) -> list[dict]: - """The draft's preview records, order-preserving, optionally filtered by kind.""" - rows = conn.execute( - "SELECT rec FROM import_draft d," - " jsonb_array_elements(d.records) WITH ORDINALITY AS r(rec, ord)" - " WHERE d.id = %(draft_id)s AND d.storefront_id = %(storefront_id)s" - " AND (%(kind)s::text IS NULL OR rec->>'kind' = %(kind)s)" - " ORDER BY ord LIMIT %(limit)s OFFSET %(offset)s", - { - "draft_id": draft_id, - "storefront_id": storefront_id, - "kind": kind, - "limit": limit, - "offset": offset, - }, - ).fetchall() - return [row[0] for row in rows] - - -def delete_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None: - conn.execute( - "DELETE FROM import_draft WHERE id = %s AND storefront_id = %s", - (draft_id, storefront_id), - ) - - -def sweep_expired_drafts(conn: psycopg.Connection) -> None: - conn.execute("DELETE FROM import_draft WHERE expires_at < now()") - - -# --------------------------------------------------------------------------- -# Import runs (history, PUC-8) -# --------------------------------------------------------------------------- - -_TERMINAL_RUN_STATUSES = ("complete", "complete_with_problems") - - -def insert_run( - conn: psycopg.Connection, - storefront_id: int, - account_id: int, - file_name: str, - dialect: str, - added: int, - updated: int, - errored: int, - status: str, -) -> int: - return conn.execute( - "INSERT INTO import_run" - " (storefront_id, account_id, file_name, dialect," - " products_added, products_updated, rows_errored, status, completed_at)" - " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, CASE WHEN %s THEN now() END)" - " RETURNING id", - ( - storefront_id, - account_id, - file_name, - dialect, - added, - updated, - errored, - status, - status in _TERMINAL_RUN_STATUSES, - ), - ).fetchone()[0] - - -def insert_run_errors(conn: psycopg.Connection, run_id: int, errors: list[dict]) -> None: - """Record per-row errors (RowError.as_json shape: line/column/message).""" - if not errors: - return - with conn.cursor() as cur: - cur.executemany( - "INSERT INTO import_run_error (run_id, line_number, column_name, message)" - " VALUES (%s, %s, %s, %s)", - [(run_id, e["line"], e["column"], e["message"]) for e in errors], - ) - - -_RUN_SELECT = ( - "SELECT r.id, r.file_name, r.dialect, r.created_at, r.completed_at, r.status," - " a.email, r.products_added, r.products_updated, r.rows_errored" - " FROM import_run r JOIN account a ON a.id = r.account_id" -) - - -def _run_dict(row: tuple) -> dict: - return { - "id": row[0], - "file_name": row[1], - "dialect": row[2], - "created_at": row[3].isoformat(), - "completed_at": row[4].isoformat() if row[4] is not None else None, - "status": row[5], - "by": row[6], - "products_added": row[7], - "products_updated": row[8], - "rows_errored": row[9], - } - - -def list_runs(conn: psycopg.Connection, storefront_id: int, limit: int, offset: int) -> list[dict]: - rows = conn.execute( - _RUN_SELECT + " WHERE r.storefront_id = %s ORDER BY r.created_at DESC, r.id DESC" - " LIMIT %s OFFSET %s", - (storefront_id, limit, offset), - ).fetchall() - runs = [_run_dict(row) for row in rows] - if not runs: - return runs - ids = [r["id"] for r in runs] - by_run = {rid: {"fetched": 0, "rejected": 0, "failed": 0, "total": 0} for rid in ids} - for rid, fetched, rejected, failed, total in conn.execute( - "SELECT import_run_id," - " count(*) FILTER (WHERE status='fetched')," - " count(*) FILTER (WHERE status IN ('rejected_low_res','rejected_not_image'))," - " count(*) FILTER (WHERE status='failed'), count(*)" - " FROM product_image WHERE import_run_id = ANY(%s) GROUP BY import_run_id", - (ids,), - ): - by_run[rid] = {"fetched": fetched, "rejected": rejected, "failed": failed, "total": total} - for r in runs: - r["image_counts"] = by_run[r["id"]] - return runs - - -def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict | None: - row = conn.execute( - _RUN_SELECT + " WHERE r.storefront_id = %s AND r.id = %s", - (storefront_id, run_id), - ).fetchone() - if row is None: - return None - run = _run_dict(row) - run["errors"] = [ - {"line": line, "column": column, "message": message} - for line, column, message in conn.execute( - "SELECT line_number, column_name, message FROM import_run_error" - " WHERE run_id = %s ORDER BY line_number, id", - (run_id,), - ) - ] - counts = run_image_counts(conn, run_id) - run["image_progress"] = {"done": counts["total"] - counts["pending"], "total": counts["total"]} - run["image_counts"] = {"fetched": counts["fetched"], "rejected": counts["rejected"], - "failed": counts["failed"]} - run["image_outcomes"] = run_image_outcomes(conn, run_id) - return run - - -# --------------------------------------------------------------------------- -# Image fetch phase (SLICE-7, SD-0002 §6.5.4) — claim/mark/count/outcomes. -# --------------------------------------------------------------------------- - - -def pending_images_for_run(conn: psycopg.Connection, run_id: int) -> list[dict]: - rows = conn.execute( - "SELECT i.id, i.source_url, p.storefront_id, i.product_id" - " FROM product_image i JOIN product p ON p.id = i.product_id" - " WHERE i.import_run_id = %s AND i.status = 'pending' ORDER BY i.id", - (run_id,), - ).fetchall() - return [{"id": r[0], "source_url": r[1], "storefront_id": r[2], "product_id": r[3]} for r in rows] - - -def claim_image_for_fetch(conn: psycopg.Connection, image_id: int) -> bool: - row = conn.execute( - "UPDATE product_image SET status = 'pending' WHERE id = %s AND status = 'pending' RETURNING id", - (image_id,), - ).fetchone() - return row is not None - - -def mark_image_fetched(conn: psycopg.Connection, image_id: int, keys: dict[str, str]) -> None: - conn.execute( - "UPDATE product_image SET status = 'fetched', failure_reason = NULL," - " key_original = %s, key_thumb = %s, key_card = %s, key_detail = %s," - " fetched_at = now() WHERE id = %s", - (keys["original"], keys["thumb"], keys["card"], keys["detail"], image_id), - ) - - -def mark_image_rejected(conn: psycopg.Connection, image_id: int, status: str, reason: str) -> None: - conn.execute( - "UPDATE product_image SET status = %s, failure_reason = %s, fetched_at = now() WHERE id = %s", - (status, reason, image_id), - ) - - -def mark_image_failed(conn: psycopg.Connection, image_id: int, reason: str) -> None: - conn.execute( - "UPDATE product_image SET status = 'failed', failure_reason = %s, fetched_at = now() WHERE id = %s", - (reason, image_id), - ) - - -def run_image_counts(conn: psycopg.Connection, run_id: int) -> dict: - row = conn.execute( - "SELECT count(*) FILTER (WHERE status = 'fetched')," - " count(*) FILTER (WHERE status IN ('rejected_low_res', 'rejected_not_image'))," - " count(*) FILTER (WHERE status = 'failed')," - " count(*) FILTER (WHERE status = 'pending'), count(*)" - " FROM product_image WHERE import_run_id = %s", - (run_id,), - ).fetchone() - return {"fetched": row[0], "rejected": row[1], "failed": row[2], "pending": row[3], "total": row[4]} - - -def set_run_status(conn: psycopg.Connection, run_id: int, status: str) -> None: - conn.execute( - "UPDATE import_run SET status = %s," - " completed_at = CASE WHEN %s THEN now() ELSE completed_at END WHERE id = %s", - (status, status in _TERMINAL_RUN_STATUSES, run_id), - ) - - -def incomplete_runs(conn: psycopg.Connection) -> list[dict]: - rows = conn.execute( - "SELECT id, storefront_id FROM import_run WHERE status = 'fetching_images' ORDER BY id", - ).fetchall() - return [{"id": r[0], "storefront_id": r[1]} for r in rows] - - -def image_for_serving(conn: psycopg.Connection, storefront_id: int, image_id: int) -> dict | None: - """Storefront-scoped lookup for the serving route: status + rendition keys (INV-14).""" - row = conn.execute( - "SELECT i.status, i.key_original, i.key_thumb, i.key_card, i.key_detail" - " FROM product_image i JOIN product p ON p.id = i.product_id" - " WHERE i.id = %s AND p.storefront_id = %s", - (image_id, storefront_id), - ).fetchone() - if row is None: - return None - return {"status": row[0], "original": row[1], "thumb": row[2], "card": row[3], "detail": row[4]} - - -def run_image_outcomes(conn: psycopg.Connection, run_id: int) -> list[dict]: - rows = conn.execute( - "SELECT p.handle, i.source_url, i.status, i.failure_reason," - " v.option1_value, v.option2_value, v.option3_value" - " FROM product_image i JOIN product p ON p.id = i.product_id" - " LEFT JOIN variant v ON v.image_id = i.id" - " WHERE i.import_run_id = %s" - " AND i.status IN ('rejected_low_res', 'rejected_not_image', 'failed')" - " ORDER BY p.handle, i.position, i.id", - (run_id,), - ).fetchall() - return [ - {"handle": r[0], "url": r[1], "outcome": r[2], "reason": r[3], - "variant": " / ".join(x for x in (r[4], r[5], r[6]) if x) or None} - for r in rows - ] - - -# --------------------------------------------------------------------------- -# Apply primitives — called inside the confirm transaction (Task 8); no commits. -# --------------------------------------------------------------------------- - - -def insert_product( - conn: psycopg.Connection, - storefront_id: int, - handle: str, - resolved_fields: dict, - option_names: tuple[str | None, str | None, str | None], -) -> int: - """INSERT with only the file-present fields; absent ones take column defaults.""" - columns = ["storefront_id", "handle", "option1_name", "option2_name", "option3_name"] - values: list[object] = [storefront_id, handle, *option_names] - for field_name, value in resolved_fields.items(): - columns.append(field_name) - values.append(value) - query = sql.SQL("INSERT INTO product ({}) VALUES ({}) RETURNING id").format( - sql.SQL(", ").join(sql.Identifier(c) for c in columns), - sql.SQL(", ").join(sql.Placeholder() for _ in columns), - ) - return conn.execute(query, values).fetchone()[0] - - -def update_product(conn: psycopg.Connection, product_id: int, changed_fields: dict) -> None: - if not changed_fields: - return - assignments = [ - sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) - for f in changed_fields - ] - query = sql.SQL("UPDATE product SET {}, updated_at = now() WHERE id = {}").format( - sql.SQL(", ").join(assignments), sql.Placeholder() - ) - conn.execute(query, [*changed_fields.values(), product_id]) - - -def insert_variant( - conn: psycopg.Connection, - product_id: int, - position: int, - options: tuple[str | None, str | None, str | None], - resolved_fields: dict, - image_id: int | None, -) -> int: - # variant_image is not a column — the caller translates it to image_id; position - # is the explicit param. Filter both defensively. - fields = { - k: v for k, v in resolved_fields.items() if k != "variant_image" and k != "position" - } - columns = [ - "product_id", - "position", - "option1_value", - "option2_value", - "option3_value", - "image_id", - ] - values: list[object] = [product_id, position, *options, image_id] - for field_name, value in fields.items(): - columns.append(field_name) - values.append(value) - query = sql.SQL("INSERT INTO variant ({}) VALUES ({}) RETURNING id").format( - sql.SQL(", ").join(sql.Identifier(c) for c in columns), - sql.SQL(", ").join(sql.Placeholder() for _ in columns), - ) - return conn.execute(query, values).fetchone()[0] - - -def update_variant( - conn: psycopg.Connection, - variant_id: int, - changed_fields: dict, - image_id: int | None | type(...) = ..., -) -> None: - """Dynamic UPDATE; image_id's Ellipsis default means "don't touch image_id".""" - fields = { - k: v for k, v in changed_fields.items() if k != "variant_image" - } - assignments = [ - sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) for f in fields - ] - values: list[object] = list(fields.values()) - if image_id is not ...: - assignments.append(sql.SQL("image_id = {}").format(sql.Placeholder())) - values.append(image_id) - if not assignments: - return - query = sql.SQL("UPDATE variant SET {}, updated_at = now() WHERE id = {}").format( - sql.SQL(", ").join(assignments), sql.Placeholder() - ) - conn.execute(query, [*values, variant_id]) - - -def get_or_create_image( - conn: psycopg.Connection, - product_id: int, - source_url: str, - position: int, - alt_text: str | None, - run_id: int, -) -> int: - """Image identity within a product is source_url (§6.3); existing rows are - returned untouched — diff emits explicit image update entries for position/alt.""" - row = conn.execute( - "SELECT id FROM product_image WHERE product_id = %s AND source_url = %s", - (product_id, source_url), - ).fetchone() - if row is not None: - return row[0] - return conn.execute( - "INSERT INTO product_image (product_id, source_url, position, alt_text, import_run_id)" - " VALUES (%s, %s, %s, %s, %s) RETURNING id", - (product_id, source_url, position, alt_text, run_id), - ).fetchone()[0] - - -def update_image(conn: psycopg.Connection, image_id: int, changed_fields: dict) -> None: - """Subset of {position, alt_text}.""" - if not changed_fields: - return - assignments = [ - sql.SQL("{} = {}").format(sql.Identifier(f), sql.Placeholder()) - for f in changed_fields - ] - query = sql.SQL("UPDATE product_image SET {} WHERE id = {}").format( - sql.SQL(", ").join(assignments), sql.Placeholder() - ) - conn.execute(query, [*changed_fields.values(), image_id]) diff --git a/backend/app/domains/products/service.py b/backend/app/domains/products/service.py deleted file mode 100644 index b2dfa6c..0000000 --- a/backend/app/domains/products/service.py +++ /dev/null @@ -1,265 +0,0 @@ -"""products service — the import/export use-case orchestration (SD-0002 §6.5). - -Coordinates codec → validate → diff → repo; owns transaction boundaries (repo -never commits). Preview is read-only against catalog tables (INV-11): validation -writes exactly one row — the import_draft. TEL events per §9.1. -""" -from __future__ import annotations - -import time -from collections.abc import Iterator -from datetime import datetime, timezone - -import psycopg - -from app.platform import config, telemetry - -from . import codec, diff, repo, serialize, validate -from .errors import ( - DraftExpired, - DraftNotFound, - EmptyCatalog, - NothingToApply, - PreviewStale, - RunNotFound, -) - - -def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int, - file_name: str, data: bytes) -> dict: - """Upload → validate → diff → persist draft (PUC-2/3; INV-11). Raises FileRejected.""" - started = time.monotonic() - # Commit the sweep before parsing: a FileRejected mid-parse must not roll - # back expired-draft cleanup along with it. - repo.sweep_expired_drafts(conn) - conn.commit() - parsed = codec.parse_csv(data) - products = validate.build_products(parsed) - catalog = repo.load_catalog(conn, storefront_id) - diff_result = diff.compute_diff(catalog, products) - draft = repo.insert_draft( - conn, storefront_id, account_id, file_name, parsed.dialect, data, - diff_result.summary, diff_result.records, diff_result.fingerprint, - parsed.unknown_columns, - ) - conn.commit() - telemetry.emit( - "import_draft_created", - storefront_id=storefront_id, - dialect=parsed.dialect, - row_count=len(parsed.rows), - adds=diff_result.summary["adds"], - updates=diff_result.summary["updates"], - unchanged=diff_result.summary["unchanged"], - errors=diff_result.summary["errors"], - unknown_columns_count=len(parsed.unknown_columns), - duration_ms=int((time.monotonic() - started) * 1000), - ) - return draft - - -def _live_draft_row(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict: - """The draft row if it exists and hasn't expired; expiry deletes lazily (§6.3).""" - row = repo.get_draft_row(conn, storefront_id, draft_id) - if row is None: - raise DraftNotFound() - if row["expires_at"] < datetime.now(timezone.utc): - repo.delete_draft(conn, storefront_id, draft_id) - conn.commit() - raise DraftExpired() - return row - - -def get_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> dict: - """The §6.4 draft payload — never file_bytes or the full records list.""" - row = _live_draft_row(conn, storefront_id, draft_id) - return { - "id": row["id"], - "file_name": row["file_name"], - "dialect": row["dialect"], - "summary": row["summary"], - "unknown_columns": row["unknown_columns"], - "expires_at": row["expires_at"].isoformat(), - } - - -def get_draft_records(conn: psycopg.Connection, storefront_id: int, draft_id: int, - kind: str | None = None, limit: int = 100, offset: int = 0) -> list[dict]: - """The draft's preview records, paged, optionally filtered by kind (PUC-3).""" - _live_draft_row(conn, storefront_id, draft_id) - return repo.draft_records(conn, storefront_id, draft_id, kind, limit, offset) - - -def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -> None: - """Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a).""" - repo.delete_draft(conn, storefront_id, draft_id) - conn.commit() - - -def confirm_draft(conn: psycopg.Connection, storefront_id: int, account_id: int, - draft_id: int) -> int: - """Apply the previewed diff in one transaction (PUC-4; INV-10/11). - - Everything is re-derived from the draft's stored file bytes against the live - catalog; a fingerprint mismatch means the catalog drifted since preview - (PreviewStale — the draft is kept so the merchant can re-validate). The apply - executes the typed plan compute_diff built alongside the preview records, so - what lands is exactly what the preview showed. rows_errored counts the - import_run_error rows recorded (one per RowError), which is what the run - detail's error table shows; the preview's errors tile counts error *products*. - """ - started = time.monotonic() - row = _live_draft_row(conn, storefront_id, draft_id) - parsed = codec.parse_csv(row["file_bytes"]) - products = validate.build_products(parsed) - catalog = repo.load_catalog(conn, storefront_id) - diff_result = diff.compute_diff(catalog, products) - if diff_result.fingerprint != row["fingerprint"]: - # Release the read snapshot; nothing written. - conn.rollback() - raise PreviewStale() - summary_counts = diff_result.summary - if summary_counts["adds"] + summary_counts["updates"] == 0: - # Release the read snapshot; nothing written. - conn.rollback() - raise NothingToApply() - error_rows = [ - error.as_json() - for plan in diff_result.plan if plan.kind == "error" - for error in plan.canonical.errors - ] - try: - run_id = repo.insert_run( - conn, storefront_id, account_id, row["file_name"], row["dialect"], - added=summary_counts["adds"], updated=summary_counts["updates"], - errored=len(error_rows), status="applying", - ) - for plan in diff_result.plan: - _apply_product_plan(conn, storefront_id, plan, run_id) - repo.insert_run_errors(conn, run_id, error_rows) - pending = repo.run_image_counts(conn, run_id)["pending"] - repo.set_run_status(conn, run_id, "fetching_images" if pending else "complete") - repo.delete_draft(conn, storefront_id, draft_id) - conn.commit() - except Exception as exc: - conn.rollback() - telemetry.emit( - "import_apply_failed", - draft_id=draft_id, - storefront_id=storefront_id, - error_class=type(exc).__name__, - ) - raise - telemetry.emit( - "import_run_completed", - run_id=run_id, - storefront_id=storefront_id, - added=summary_counts["adds"], - updated=summary_counts["updates"], - errored=len(error_rows), - duration_ms=int((time.monotonic() - started) * 1000), - ) - return run_id - - -def _apply_product_plan(conn: psycopg.Connection, storefront_id: int, - plan: diff.ProductPlan, run_id: int) -> None: - """Execute one product's plan inside the confirm transaction (no commits here).""" - if plan.kind == "add": - # Title is a canonical attribute, not a fields{} entry — non-error - # products always carry one (validate guarantees it). - product_fields = {"title": plan.canonical.title} - product_fields.update(diff.resolved_product_fields(plan.canonical)) - product_id = repo.insert_product( - conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names - ) - image_ids: dict[str, int] = {} - elif plan.kind == "update": - product_id = plan.catalog.id - repo.update_product(conn, product_id, plan.product_changes) - image_ids = {image.source_url: image.id for image in plan.catalog.images} - else: - return - - # Images first, so variants' variant_image URLs resolve to ids: validate puts - # every variant_image URL into canonical.images, so each URL is in either the - # catalog map (existing image) or the adds below. - for image_plan in plan.image_plans: - if image_plan.kind == "add": - image_ids[image_plan.source_url] = repo.get_or_create_image( - conn, product_id, image_plan.source_url, image_plan.position, - image_plan.alt_text, run_id, - ) - else: - repo.update_image(conn, image_plan.image_id, image_plan.changes) - - for variant_plan in plan.variant_plans: - if variant_plan.kind == "add": - fields = diff.resolved_variant_fields(variant_plan.canonical, variant_plan.file_order) - # diff time resolved any cleared position to file order; the - # file_order fallback covers an absent position column. - position = fields.get("position") or variant_plan.file_order - url = fields.get("variant_image") - image_id = image_ids[url] if url else None - repo.insert_variant( - conn, product_id, position, variant_plan.canonical.options, fields, image_id - ) - elif "variant_image" in variant_plan.changes: - url = variant_plan.changes["variant_image"] - repo.update_variant( - conn, variant_plan.catalog_id, variant_plan.changes, - image_id=image_ids[url] if url else None, - ) - else: - repo.update_variant(conn, variant_plan.catalog_id, variant_plan.changes) - - -def list_runs(conn: psycopg.Connection, storefront_id: int, - limit: int = 50, offset: int = 0) -> list[dict]: - """The storefront's import history, newest first (PUC-8).""" - return repo.list_runs(conn, storefront_id, limit, offset) - - -def get_run(conn: psycopg.Connection, storefront_id: int, run_id: int) -> dict: - """One run's §6.4 detail payload, errors included.""" - run = repo.get_run(conn, storefront_id, run_id) - if run is None: - raise RunNotFound() - return run - - -def export_catalog( - conn: psycopg.Connection, storefront_id: int, status_filter: str -) -> Iterator[str]: - """Stream the storefront's catalog as canonical CSV (PUC-9; INV-12 codec). - - Read-only: builds the snapshot, then streams the serializer over it. TEL-3 - is emitted once the stream is exhausted, with the product count and elapsed - time. Raises EmptyCatalog before yielding anything if the (filtered) catalog - is empty, so the BFF can answer 409 cleanly with no partial body. - """ - started = time.monotonic() - snapshot = repo.export_catalog(conn, storefront_id, status_filter) - if not snapshot: - raise EmptyCatalog() - - def _stream() -> Iterator[str]: - yield from serialize.catalog_to_csv(snapshot, base_url=config.public_base_url()) - telemetry.emit( - "catalog_exported", - storefront_id=storefront_id, - status_filter=status_filter, - product_count=len(snapshot), - duration_ms=int((time.monotonic() - started) * 1000), - ) - - return _stream() - - -def summary(conn: psycopg.Connection, storefront_id: int) -> dict: - """The products dashboard counts (§6.4).""" - return { - "product_count": repo.product_count(conn, storefront_id), - "image_problem_count": repo.image_problem_count(conn, storefront_id), - "latest_run_id": repo.latest_run_id(conn, storefront_id), - } diff --git a/backend/app/domains/storefronts/__init__.py b/backend/app/domains/storefronts/__init__.py deleted file mode 100644 index d2db7f1..0000000 --- a/backend/app/domains/storefronts/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""storefronts domain — storefront entity, membership, the one-storefront guard. - -Owns INV-4 (one storefront per account is a service-layer rule) and INV-5 (tenant rows -carry storefront_id). Knows nothing about identity (that is the accounts domain). Imported -via this package surface only (§6.2). -""" -from __future__ import annotations - -from .errors import AlreadyOwnsStorefront, StorefrontsError -from .models import Storefront -from .service import create_storefront, storefront_for - -__all__ = [ - "Storefront", - "StorefrontsError", - "AlreadyOwnsStorefront", - "create_storefront", - "storefront_for", -] diff --git a/backend/app/domains/storefronts/errors.py b/backend/app/domains/storefronts/errors.py deleted file mode 100644 index 699f1a7..0000000 --- a/backend/app/domains/storefronts/errors.py +++ /dev/null @@ -1,10 +0,0 @@ -"""storefronts domain errors.""" -from __future__ import annotations - - -class StorefrontsError(Exception): - """Base for storefronts-domain errors.""" - - -class AlreadyOwnsStorefront(StorefrontsError): - """INV-4: the account already has its one storefront (PUC-7).""" diff --git a/backend/app/domains/storefronts/models.py b/backend/app/domains/storefronts/models.py deleted file mode 100644 index 4bedb2f..0000000 --- a/backend/app/domains/storefronts/models.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Storefront record — the §6.3 entity as the domain returns it.""" -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class Storefront: - id: int - name: str diff --git a/backend/app/domains/storefronts/service.py b/backend/app/domains/storefronts/service.py deleted file mode 100644 index 4249dc2..0000000 --- a/backend/app/domains/storefronts/service.py +++ /dev/null @@ -1,56 +0,0 @@ -"""storefronts — the storefront + membership service (SD-0001 §6.5). - -Owns the storefront entity, the account<->storefront membership (INV-5), the entry-routing -answer (storefront_for), and INV-4's one-storefront guard — the single deletable check that -makes one-per-account an MVP rule, not a schema law. Never mints identity: the BFF passes -account_id and email down. -""" -from __future__ import annotations - -import psycopg - -from .errors import AlreadyOwnsStorefront -from .models import Storefront - - -def _default_name(email: str) -> str: - """§6.3: a blank name stores a generated default — never NULL (corpus 14.01.0026).""" - return f"{email.split('@', 1)[0]}'s storefront" - - -def storefront_for(conn: psycopg.Connection, account_id: int) -> Storefront | None: - """The entry-routing answer (§6.5): which storefront, if any, this account has.""" - row = conn.execute( - "SELECT s.id, s.name FROM storefront s" - " JOIN storefront_membership m ON m.storefront_id = s.id" - " WHERE m.account_id = %s ORDER BY m.created_at LIMIT 1", - (account_id,), - ).fetchone() - return Storefront(id=row[0], name=row[1]) if row else None - - -def create_storefront( - conn: psycopg.Connection, account_id: int, email: str, name: str | None -) -> Storefront: - """Create the account's one storefront + owner membership (PUC-4; INV-4, INV-5). - - Guard + insert run as one atomic unit: a transaction-scoped advisory lock keyed by - account_id serializes concurrent creates for the same account, so the second of two - racing requests sees the first's committed membership and is refused (§6.5). - """ - conn.execute("SELECT pg_advisory_xact_lock(%s)", (account_id,)) - existing = storefront_for(conn, account_id) - if existing is not None: - conn.rollback() # release the advisory lock; nothing was written - raise AlreadyOwnsStorefront() - final_name = (name or "").strip() or _default_name(email) - sf_id = conn.execute( - "INSERT INTO storefront (name) VALUES (%s) RETURNING id", (final_name,) - ).fetchone()[0] - conn.execute( - "INSERT INTO storefront_membership (account_id, storefront_id, role)" - " VALUES (%s, %s, 'owner')", - (account_id, sf_id), - ) - conn.commit() - return Storefront(id=sf_id, name=final_name) diff --git a/backend/app/main.py b/backend/app/main.py index 99f6583..780298a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,32 +1,31 @@ -"""ecomm backend — FastAPI app factory + the REST BFF. +"""ecomm backend — FastAPI app factory + the network-service seed BFF. -SLICE-1 mounted /healthz; SLICE-2 adds the /api/auth/* identity endpoints (§6.4). The BFF -translates HTTP <-> domain calls and owns no business logic (INV-6): every rule lives in -the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), and builds -the configured mailer (INV-8) at startup. SLICE-3 adds POST /api/storefronts and feeds the -_storefront_for seam from the storefronts domain. SLICE-5 adds the /api/products/* import -spine (SD-0002 §6.4): each endpoint is a gate + one products-domain call + error mapping. +This is the pivot to a network-service seed: a minimal FastAPI surface carrying only +/healthz and the /api/auth/* identity endpoints (§6.4), backed by the accounts domain. +The BFF translates HTTP <-> domain calls and owns no business logic (INV-6): every rule +lives in the accounts domain. create_app() opens the pool, self-migrates (INV-1, INV-7), +and builds the configured mailer (INV-8 — auth needs the mailer) at startup. + +The catalog-normalization library (app.domains.products: codec/dialect/models/serialize/ +validate/diff) is kept as importable modules but has no HTTP surface yet — the storefront +and products import/export/image spine was stripped in the network-seed reset. """ from __future__ import annotations import logging import sys -from concurrent.futures import ThreadPoolExecutor from contextlib import asynccontextmanager from pathlib import Path from typing import Any import psycopg -from fastapi import Depends, FastAPI, File, Query, Response, UploadFile -from fastapi import Path as ApiPath -from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse -from fastapi.staticfiles import StaticFiles +from fastapi import Depends, FastAPI, Response +from fastapi.responses import JSONResponse from pydantic import BaseModel -from app.domains import accounts, products, storefronts +from app.domains import accounts from app.platform import config, db from app.platform import mailer as mailer_mod -from app.platform import objectstore as objectstore_mod from app.platform.deps import SESSION_COOKIE, get_conn, get_mailer, get_session from app.platform.mailer import Mailer from app.platform import session as session_mod @@ -50,41 +49,11 @@ class VerifyBody(BaseModel): code: str -class CreateStorefrontBody(BaseModel): - name: str | None = None - - def _error(status: int, code: str, message: str, **extra: Any) -> JSONResponse: """The shared §6.4 error envelope: {"error": {"code", "message", ...}}.""" return JSONResponse(status_code=status, content={"error": {"code": code, "message": message, **extra}}) -def _storefront_for(conn: psycopg.Connection, account: accounts.Account) -> dict | None: - """The entry-routing answer: which storefront, if any, this account has (§6.5).""" - sf = storefronts.storefront_for(conn, account.id) - return {"id": sf.id, "name": sf.name} if sf else None - - -def _merchant_gate( - conn: psycopg.Connection, sess: dict | None -) -> JSONResponse | tuple[accounts.Account, storefronts.Storefront]: - """The shared /api/products/* gate: a signed-in account that has its storefront. - - Returns the (account, storefront) pair, or the ready-to-return error response — - 401 with no session, 404 before the storefront exists (INV-14: every products - call is storefront-scoped, so there is nothing to address yet). - """ - if sess is None: - return _error(401, "unauthenticated", "You are not signed in.") - account = accounts.get_account(conn, sess["account_id"]) - if account is None: - return _error(401, "unauthenticated", "You are not signed in.") - sf = storefronts.storefront_for(conn, account.id) - if sf is None: - return _error(404, "no_storefront", "Create your storefront first.") - return account, sf - - def _ensure_app_logging() -> None: """Surface the app's own `ecomm.*` INFO logs on stderr (idempotent). @@ -114,7 +83,7 @@ def _set_session_cookie(response: Response, account: accounts.Account) -> None: ) -def create_app(database_url: str | None = None, static_dir: str | Path | None = None) -> FastAPI: +def create_app(database_url: str | None = None) -> FastAPI: _ensure_app_logging() dsn = database_url or config.database_url() @@ -124,18 +93,9 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = with app.state.pool.connection() as conn: db.migrate(conn) # self-migrate at startup (INV-1, INV-7) app.state.mailer = mailer_mod.build_mailer(config.mailer_kind()) # INV-8 - app.state.objectstore = objectstore_mod.build_objectstore(config.objectstore_kind()) - app.state.image_allow_private = config.image_fetch_allow_private() - app.state.image_runner = ThreadPoolExecutor(max_workers=2, thread_name_prefix="img-run") - # §6.9 startup recovery — resume runs stuck mid image-fetch, off the request path. - app.state.image_runner.submit( - products.recover_incomplete_runs, app.state.pool, - app.state.objectstore, app.state.image_allow_private, - ) try: yield finally: - app.state.image_runner.shutdown(wait=False) app.state.pool.close() app = FastAPI(title="ecomm", version=_APP_VERSION, lifespan=lifespan) @@ -180,7 +140,7 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = @app.post("/api/auth/verify") def verify(body: VerifyBody, conn: psycopg.Connection = Depends(get_conn)): - """Verify a code, start a session, and answer entry routing (§6.4/§6.5).""" + """Verify a code and start a session (§6.4).""" try: account, created = accounts.verify(conn, body.email, body.code) except accounts.InvalidEmail: @@ -196,7 +156,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = return _error(400, "code_exhausted", "Too many attempts — request a fresh code.") payload = { "account": {"email": account.email}, - "storefront": _storefront_for(conn, account), "created": created, } resp = JSONResponse(status_code=200, content=payload) @@ -205,13 +164,13 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = @app.get("/api/auth/me") def me(conn: psycopg.Connection = Depends(get_conn), sess: dict | None = Depends(get_session)): - """The signed-in account + entry-routing answer, or 401 (§6.4/§6.5).""" + """The signed-in account, or 401 (§6.4).""" if sess is None: return _error(401, "unauthenticated", "You are not signed in.") account = accounts.get_account(conn, sess["account_id"]) if account is None: return _error(401, "unauthenticated", "You are not signed in.") - return {"account": {"email": account.email}, "storefront": _storefront_for(conn, account)} + return {"account": {"email": account.email}} @app.post("/api/auth/logout") def logout(): @@ -220,251 +179,6 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = resp.delete_cookie(SESSION_COOKIE, path="/") return resp - @app.post("/api/storefronts") - def create_storefront( - body: CreateStorefrontBody, - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """Create the account's one storefront (§6.4; PUC-4, INV-4/PUC-7 on refusal).""" - if sess is None: - return _error(401, "unauthenticated", "You are not signed in.") - account = accounts.get_account(conn, sess["account_id"]) - if account is None: - return _error(401, "unauthenticated", "You are not signed in.") - try: - sf = storefronts.create_storefront(conn, account.id, account.email, body.name) - except storefronts.AlreadyOwnsStorefront: - return _error( - 409, "already_owns_storefront", - "Your account already has its storefront — ecomm is one storefront per account today.", - ) - return JSONResponse(status_code=201, content={"id": sf.id, "name": sf.name}) - - @app.post("/api/products/imports") - async def import_upload( - file: UploadFile = File(...), - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """Upload a CSV → validated import draft (§6.4; PUC-2, PUC-5/5a on rejection).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - account, sf = gate - data = await file.read() - if len(data) > products.MAX_FILE_BYTES: - return _error(413, "file_too_large", "This file is larger than 10 MB.") - try: - draft = products.import_validate(conn, sf.id, account.id, file.filename or "upload.csv", data) - except products.FileRejected as exc: - return _error(400, exc.code, exc.message) - return JSONResponse(status_code=201, content=draft) - - @app.get("/api/products/imports/drafts/{draft_id}") - def get_import_draft( - draft_id: int, - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """One draft's preview payload — summary, never the file bytes (§6.4; PUC-3).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - try: - return products.get_draft(conn, sf.id, draft_id) - except products.DraftNotFound: - return _error(404, "not_found", "No such import preview.") - except products.DraftExpired: - return _error(410, "draft_expired", "This preview expired — upload the file again.") - - @app.get("/api/products/imports/drafts/{draft_id}/records") - def get_import_draft_records( - draft_id: int, - kind: str | None = Query(default=None, pattern="^(add|update|unchanged|error)$"), - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """The draft's per-product preview records, paged + kind-filtered (§6.4; PUC-3).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - try: - records = products.get_draft_records(conn, sf.id, draft_id, kind, limit, offset) - except products.DraftNotFound: - return _error(404, "not_found", "No such import preview.") - except products.DraftExpired: - return _error(410, "draft_expired", "This preview expired — upload the file again.") - return {"records": records} - - @app.post("/api/products/imports/drafts/{draft_id}/confirm") - def confirm_import_draft( - draft_id: int, - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """Apply the previewed diff as one import run (§6.4; PUC-4, INV-10/11).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - account, sf = gate - try: - run_id = products.confirm_draft(conn, sf.id, account.id, draft_id) - except products.DraftNotFound: - return _error(404, "not_found", "No such import preview.") - except products.DraftExpired: - return _error(410, "draft_expired", "This preview expired — upload the file again.") - except products.PreviewStale: - return _error( - 409, "preview_stale", - "Your catalog changed since this preview — upload the file again.", - ) - except products.NothingToApply: - return _error( - 409, "nothing_to_apply", - "Nothing to change — your catalog already matches this file.", - ) - app.state.image_runner.submit( - products.run_image_phase, app.state.pool, app.state.objectstore, - run_id, app.state.image_allow_private, - ) - return JSONResponse(status_code=201, content={"run_id": run_id}) - - @app.delete("/api/products/imports/drafts/{draft_id}") - def discard_import_draft( - draft_id: int, - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """Discard the draft, no trace kept; idempotent (§6.4; PUC-3a).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - products.discard_draft(conn, sf.id, draft_id) - return Response(status_code=204) - - @app.get("/api/products/imports/runs") - def list_import_runs( - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """The storefront's import history, newest first (§6.4; PUC-8).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - return {"runs": products.list_runs(conn, sf.id, limit, offset)} - - @app.get("/api/products/imports/runs/{run_id}") - def get_import_run( - run_id: int, - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """One run's detail payload, errors included (§6.4; PUC-8).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - try: - return products.get_run(conn, sf.id, run_id) - except products.RunNotFound: - return _error(404, "not_found", "No such import run.") - - @app.get("/api/products/summary") - def products_summary( - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """The products dashboard counts (§6.4).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - return products.summary(conn, sf.id) - - @app.get("/api/products/export") - def export_products( - status: str = Query(default="all", pattern="^(all|active|draft|archived)$"), - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """Stream the catalog as canonical CSV, optionally status-filtered (§6.4; PUC-9).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - try: - stream = products.export_catalog(conn, sf.id, status) - except products.EmptyCatalog: - return _error(409, "empty_catalog", "There are no products to export.") - return StreamingResponse( - stream, - media_type="text/csv", - headers={"content-disposition": 'attachment; filename="ecomm-products-export.csv"'}, - ) - - _RENDITION_CT = {"thumb": "image/webp", "card": "image/webp", - "detail": "image/webp", "original": "application/octet-stream"} - - @app.get("/api/products/images/{image_id}/{rendition}") - def serve_product_image( - image_id: int, - rendition: str = ApiPath(pattern="^(original|thumb|card|detail)$"), - conn: psycopg.Connection = Depends(get_conn), - sess: dict | None = Depends(get_session), - ): - """Serve a hosted image rendition, storefront-authorized + immutable cache (§6.4, INV-16).""" - gate = _merchant_gate(conn, sess) - if isinstance(gate, JSONResponse): - return gate - _account, sf = gate - rec = products.image_for_serving(conn, sf.id, image_id) - if rec is None: - return _error(404, "not_found", "No such image.") - if rec["status"] != "fetched": - return _error(409, "not_fetched", "This image has not been fetched yet.") - key = rec[rendition] - if not key: - return _error(404, "not_found", "No such rendition.") - try: - data = app.state.objectstore.get(key) - except objectstore_mod.ObjectNotFound: - return _error(404, "not_found", "No such image.") - return Response(content=data, media_type=_RENDITION_CT[rendition], - headers={"Cache-Control": "public, max-age=31536000, immutable"}) - - @app.get("/api/products/sample.csv") - def products_sample_csv(): - """The DOC-3 worked-example CSV. Documentation, so no auth gate (§6.4).""" - return PlainTextResponse( - products.SAMPLE_CSV_PATH.read_text(), - media_type="text/csv", - headers={"content-disposition": 'attachment; filename="ecomm-products-sample.csv"'}, - ) - - @app.get("/api/products/columns.md") - def products_columns_md(): - """The DOC-2 column reference. Documentation, so no auth gate (§6.4).""" - return PlainTextResponse( - products.COLUMNS_MD_PATH.read_text(), - media_type="text/markdown; charset=utf-8", - ) - - # Deployed topology (launch-app SPEC §2): nginx proxies everything here, so the - # backend serves the built SPA. Mounted LAST so /healthz and /api/* win. In dev the - # dist dir doesn't exist (Vite serves the frontend) and the mount is skipped. - spa_dir = Path(static_dir) if static_dir is not None else _REPO_ROOT / "frontend" / "dist" - if (spa_dir / "index.html").is_file(): - app.mount("/", StaticFiles(directory=spa_dir, html=True), name="spa") - return app diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py index aaea47c..3432c29 100644 --- a/backend/tests/test_auth_endpoints.py +++ b/backend/tests/test_auth_endpoints.py @@ -57,7 +57,7 @@ def test_verify_sets_cookie_and_returns_shape(fresh_db_url): resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code}) assert resp.status_code == 200 data = resp.json() - assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True} + assert data == {"account": {"email": "merchant@example.com"}, "created": True} assert "ecomm_session" in resp.cookies @@ -115,7 +115,7 @@ def test_me_returns_account_with_session(fresh_db_url): client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)}) resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify assert resp.status_code == 200 - assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None} + assert resp.json() == {"account": {"email": "merchant@example.com"}} def test_puc_09_logout_clears_session(fresh_db_url): diff --git a/backend/tests/test_bootstrap.py b/backend/tests/test_bootstrap.py index ec1db53..d338f69 100644 --- a/backend/tests/test_bootstrap.py +++ b/backend/tests/test_bootstrap.py @@ -1,6 +1,7 @@ """INV-1's enforcement (SD-0001 §6.8): from an empty database, one test walks the whole -flow — request-code -> verify -> create-storefront -> /me — asserting no step needed -seeded state. Migration idempotence (the second INV-1 test) lives in test_migrations.py.""" +surviving flow — request-code -> verify -> /me — asserting no step needed seeded state. +The network-seed reset removed the create-storefront step from this flow. Migration +idempotence (the second INV-1 test) lives in test_migrations.py.""" import re from fastapi.testclient import TestClient @@ -26,17 +27,9 @@ def test_inv_1_bootstrap_whole_flow_from_empty(fresh_db_url): ) assert verified.status_code == 200 assert verified.json()["created"] is True - assert verified.json()["storefront"] is None # -> create-storefront (PUC-5) + assert verified.json()["account"] == {"email": "first@example.com"} - # PUC-4: create the storefront (blank name -> generated default) - created = client.post("/api/storefronts", json={}) - assert created.status_code == 201 - assert created.json()["name"] == "first's storefront" - - # PUC-6/PUC-8: the admin answer — storefront + email from /me alone + # the admin answer — the signed-in account from /me alone (cookie set by verify) me = client.get("/api/auth/me") assert me.status_code == 200 - assert me.json() == { - "account": {"email": "first@example.com"}, - "storefront": created.json(), - } + assert me.json() == {"account": {"email": "first@example.com"}} diff --git a/backend/tests/test_products_endpoints.py b/backend/tests/test_products_endpoints.py deleted file mode 100644 index 66de184..0000000 --- a/backend/tests/test_products_endpoints.py +++ /dev/null @@ -1,146 +0,0 @@ -"""§6.4 /api/products/* endpoint scenarios (PUC-2/3/3a/4/5/5a/8 + gates).""" -import io -import re -from contextlib import contextmanager - -from fastapi.testclient import TestClient - -from app.main import create_app - -GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\n" - - -@contextmanager -def _merchant_client(fresh_db_url, email="m@example.com"): - with TestClient(create_app(database_url=fresh_db_url)) as client: - client.post("/api/auth/request-code", json={"email": email}) - code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) - client.post("/api/auth/verify", json={"email": email, "code": code}) - client.post("/api/storefronts", json={}) - yield client - - -def _upload(client, data=GOOD_CSV, name="cat.csv"): - return client.post("/api/products/imports", files={"file": (name, io.BytesIO(data), "text/csv")}) - - -def test_upload_returns_201_draft(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - resp = _upload(client) - assert resp.status_code == 201 - body = resp.json() - assert body["summary"]["adds"] == 1 and body["dialect"] == "canonical" - - -def test_upload_rejections_carry_codes(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - resp = _upload(client, b"Vendor\nAcme\n") - assert resp.status_code == 400 - assert resp.json()["error"]["code"] == "missing_required_column" - resp = _upload(client, b"Handle,Title\n" + b"x" * (10 * 1024 * 1024 + 1)) - assert resp.status_code == 413 - - -def test_unauthenticated_401_and_no_storefront_404(fresh_db_url): - with TestClient(create_app(database_url=fresh_db_url)) as client: - assert _upload(client).status_code == 401 - client.post("/api/auth/request-code", json={"email": "x@example.com"}) - code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) - client.post("/api/auth/verify", json={"email": "x@example.com", "code": code}) - assert _upload(client).status_code == 404 - - -def test_preview_confirm_run_flow(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - draft = _upload(client).json() - recs = client.get(f"/api/products/imports/drafts/{draft['id']}/records").json()["records"] - assert recs[0]["kind"] == "add" - run_id = client.post(f"/api/products/imports/drafts/{draft['id']}/confirm").json()["run_id"] - run = client.get(f"/api/products/imports/runs/{run_id}").json() - assert run["products_added"] == 1 and run["by"] == "m@example.com" - assert client.get("/api/products/summary").json()["product_count"] == 1 - assert client.get("/api/products/imports/runs").json()["runs"][0]["id"] == run_id - - -def test_cancel_no_trace_puc3a(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - draft = _upload(client).json() - assert client.delete(f"/api/products/imports/drafts/{draft['id']}").status_code == 204 - assert client.get(f"/api/products/imports/drafts/{draft['id']}").status_code == 404 - assert client.get("/api/products/imports/runs").json()["runs"] == [] - - -def test_confirm_conflicts(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - d1 = _upload(client).json() - client.post(f"/api/products/imports/drafts/{d1['id']}/confirm") - d2 = _upload(client).json() - resp = client.post(f"/api/products/imports/drafts/{d2['id']}/confirm") - assert resp.status_code == 409 and resp.json()["error"]["code"] == "nothing_to_apply" - - -def test_sample_csv_served(fresh_db_url): - with TestClient(create_app(database_url=fresh_db_url)) as client: - resp = client.get("/api/products/sample.csv") - assert resp.status_code == 200 - assert resp.headers["content-type"].startswith("text/csv") - assert resp.text.startswith("Handle,Title,") - - -def test_sample_csv_imports_clean(fresh_db_url): - """DOC-3 honesty: our own sample must validate with zero errors.""" - with _merchant_client(fresh_db_url) as client: - sample = client.get("/api/products/sample.csv").content - body = _upload(client, sample, "sample.csv").json() - assert body["summary"]["errors"] == 0 and body["summary"]["adds"] == 2 - - -def test_columns_md_served(fresh_db_url): - """DOC-2 column reference is app-served, unauthenticated documentation (§6.4).""" - with TestClient(create_app(database_url=fresh_db_url)) as client: - resp = client.get("/api/products/columns.md") - assert resp.status_code == 200 - assert "text/markdown" in resp.headers["content-type"] - body = resp.text - assert "Body (HTML)" in body # Shopify dialect notes present - assert "`Handle`" in body # canonical columns present - - -def test_export_returns_canonical_csv(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - draft = _upload(client).json() - client.post(f"/api/products/imports/drafts/{draft['id']}/confirm") - resp = client.get("/api/products/export") - assert resp.status_code == 200 - assert resp.headers["content-type"].startswith("text/csv") - assert "attachment" in resp.headers["content-disposition"] - body = resp.text - assert body.splitlines()[0].startswith("Handle,") - assert "moon-mug" in body - - -def test_export_status_filter_respected(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - # GOOD_CSV's moon-mug has no Status column → defaults to active. - draft = _upload(client).json() - client.post(f"/api/products/imports/drafts/{draft['id']}/confirm") - assert "moon-mug" in client.get("/api/products/export?status=active").text - # No archived products → 409. - assert client.get("/api/products/export?status=archived").status_code == 409 - - -def test_export_empty_catalog_409(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - resp = client.get("/api/products/export") - assert resp.status_code == 409 - assert resp.json()["error"]["code"] == "empty_catalog" - - -def test_export_requires_merchant(fresh_db_url): - with TestClient(create_app(database_url=fresh_db_url)) as client: - assert client.get("/api/products/export").status_code == 401 - - -def test_export_bad_status_422(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - assert client.get("/api/products/export?status=bogus").status_code == 422 diff --git a/backend/tests/test_products_export.py b/backend/tests/test_products_export.py deleted file mode 100644 index 959073b..0000000 --- a/backend/tests/test_products_export.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Export: status-filtered catalog snapshot + the streamed service (PUC-9, TEL-3).""" -import csv -import io -import json -import logging - -import psycopg -import pytest - -from app.domains import products -from app.domains.products import repo -from app.platform import db - -CSV = ( - b"Handle,Title,Vendor,Status,Variant Price\n" - b"active-mug,Active Mug,Acme,active,18.00\n" - b"draft-tee,Draft Tee,Acme,draft,24.00\n" -) - - -@pytest.fixture() -def migrated_conn(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn) - yield conn - - -@pytest.fixture() -def merchant(migrated_conn): - acct = migrated_conn.execute( - "INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0] - sf = migrated_conn.execute( - "INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0] - migrated_conn.execute( - "INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf)) - migrated_conn.commit() - return {"account_id": acct, "storefront_id": sf} - - -def _seed(conn, merchant): - draft = products.import_validate(conn, merchant["storefront_id"], merchant["account_id"], "c.csv", CSV) - products.confirm_draft(conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - - -def test_export_all_returns_both(migrated_conn, merchant): - _seed(migrated_conn, merchant) - snap = repo.export_catalog(migrated_conn, merchant["storefront_id"], "all") - assert {p.handle for p in snap} == {"active-mug", "draft-tee"} - - -def test_export_status_filter(migrated_conn, merchant): - _seed(migrated_conn, merchant) - active = repo.export_catalog(migrated_conn, merchant["storefront_id"], "active") - assert [p.handle for p in active] == ["active-mug"] - draft = repo.export_catalog(migrated_conn, merchant["storefront_id"], "draft") - assert [p.handle for p in draft] == ["draft-tee"] - - -@pytest.fixture() -def telemetry_propagation(): - """create_app() sets propagate=False on the parent "ecomm" logger - (main._ensure_app_logging), which hides ecomm.telemetry records from caplog's - root-logger handler whenever an API test ran first. Restore propagation here.""" - lg = logging.getLogger("ecomm") - prior = lg.propagate - lg.propagate = True - yield - lg.propagate = prior - - -def test_export_streams_canonical_csv(migrated_conn, merchant): - _seed(migrated_conn, merchant) - text = "".join(products.export_catalog(migrated_conn, merchant["storefront_id"], "all")) - rows = list(csv.DictReader(io.StringIO(text))) - assert {r["Handle"] for r in rows} == {"active-mug", "draft-tee"} - assert rows[0]["Handle"] == "active-mug" # sorted by handle - - -def test_export_empty_raises(migrated_conn, merchant): - # No catalog at all → empty. - with pytest.raises(products.EmptyCatalog): - list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all")) - - -def test_export_empty_after_filter_raises(migrated_conn, merchant): - _seed(migrated_conn, merchant) # only active + draft exist - with pytest.raises(products.EmptyCatalog): - list(products.export_catalog(migrated_conn, merchant["storefront_id"], "archived")) - - -def test_tel3_emitted(migrated_conn, merchant, caplog, telemetry_propagation): - _seed(migrated_conn, merchant) - with caplog.at_level(logging.INFO, logger="ecomm.telemetry"): - list(products.export_catalog(migrated_conn, merchant["storefront_id"], "all")) - events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"] - exported = [e for e in events if e["event"] == "catalog_exported"] - assert len(exported) == 1 - assert exported[0]["product_count"] == 2 - assert exported[0]["status_filter"] == "all" - assert "duration_ms" in exported[0] diff --git a/backend/tests/test_products_hosted.py b/backend/tests/test_products_hosted.py deleted file mode 100644 index 9c145cb..0000000 --- a/backend/tests/test_products_hosted.py +++ /dev/null @@ -1,30 +0,0 @@ -"""hosted-image URL helpers — round-trip recognition (SD-0002 §6.3.1, INV-12).""" -from app.domains.products import hosted - - -def test_build_relative_when_no_base(): - assert hosted.image_url("", 42, "detail") == "/api/products/images/42/detail" - - -def test_build_absolute_with_base(): - url = hosted.image_url("https://ecomm-ppe.wiggleverse.org", 42, "detail") - assert url == "https://ecomm-ppe.wiggleverse.org/api/products/images/42/detail" - - -def test_parse_absolute_hosted_url(): - url = "https://market.wiggleverse.org/api/products/images/7/detail" - assert hosted.parse_image_id(url) == 7 - - -def test_parse_relative_hosted_url(): - assert hosted.parse_image_id("/api/products/images/7/card") == 7 - - -def test_parse_ignores_query_and_trailing(): - assert hosted.parse_image_id("/api/products/images/7/detail?v=1") == 7 - - -def test_parse_non_hosted_returns_none(): - assert hosted.parse_image_id("https://cdn.example.com/a/b.png") is None - assert hosted.parse_image_id("/api/products/images/abc/detail") is None - assert hosted.parse_image_id("/api/products/images/7") is None diff --git a/backend/tests/test_products_image_serving.py b/backend/tests/test_products_image_serving.py deleted file mode 100644 index fad07d6..0000000 --- a/backend/tests/test_products_image_serving.py +++ /dev/null @@ -1,78 +0,0 @@ -"""GET /api/products/images/{id}/{rendition} — authorize, stream, immutable cache (§6.4, INV-14/16).""" -import psycopg -from contextlib import contextmanager - -from fastapi.testclient import TestClient - -from app.main import create_app -from app.domains import products # noqa: F401 (ensures package import path) - -from test_products_endpoints import _merchant_client # reuse the signed-in client - - -def _seed_image(fresh_db_url, status="fetched", with_detail_bytes=None, store=None, - email_storefront_only=True): - """Insert a product + image for the (only) storefront; set keys; return (storefront_id, image_id, keys).""" - with psycopg.connect(fresh_db_url) as conn: - sf = conn.execute("SELECT id FROM storefront ORDER BY id LIMIT 1").fetchone()[0] - pid = conn.execute( - "INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id", - (sf,)).fetchone()[0] - iid = conn.execute( - "INSERT INTO product_image (product_id, source_url, position, status)" - " VALUES (%s,'https://m/a.png',1,%s) RETURNING id", (pid, status)).fetchone()[0] - keys = {r: f"storefronts/{sf}/product-images/{iid}/{r}" for r in ("original", "thumb", "card", "detail")} - if status == "fetched": - conn.execute( - "UPDATE product_image SET key_original=%s, key_thumb=%s, key_card=%s, key_detail=%s WHERE id=%s", - (keys["original"], keys["thumb"], keys["card"], keys["detail"], iid)) - conn.commit() - if with_detail_bytes is not None and store is not None: - store.put(keys["detail"], with_detail_bytes, "image/webp") - return sf, iid, keys - - -def test_serves_fetched_rendition_with_immutable_cache(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - _sf, iid, _keys = _seed_image(fresh_db_url, status="fetched", - with_detail_bytes=b"WEBPDATA", store=client.app.state.objectstore) - resp = client.get(f"/api/products/images/{iid}/detail") - assert resp.status_code == 200 - assert resp.headers["content-type"] == "image/webp" - assert "immutable" in resp.headers.get("cache-control", "") - assert resp.content == b"WEBPDATA" - - -def test_not_fetched_returns_409(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - _sf, iid, _keys = _seed_image(fresh_db_url, status="pending") - resp = client.get(f"/api/products/images/{iid}/detail") - assert resp.status_code == 409 - assert resp.json()["error"]["code"] == "not_fetched" - - -def test_other_storefronts_image_is_404(fresh_db_url): - # Sign in as merchant B first (this migrates the DB), then seed an image - # belonging to a *different* storefront A — it must be invisible to B. - with _merchant_client(fresh_db_url, email="b@example.com") as client: - with psycopg.connect(fresh_db_url) as conn: - a = conn.execute("INSERT INTO account (email) VALUES ('a@example.com') RETURNING id").fetchone()[0] - sfa = conn.execute("INSERT INTO storefront (name) VALUES ('A') RETURNING id").fetchone()[0] - conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (a, sfa)) - pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,'lamp','Lamp') RETURNING id", (sfa,)).fetchone()[0] - iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status) VALUES (%s,'u',1,'fetched') RETURNING id", (pid,)).fetchone()[0] - conn.commit() - resp = client.get(f"/api/products/images/{iid}/detail") - assert resp.status_code == 404 - - -def test_unauthenticated_is_401(fresh_db_url): - with TestClient(create_app(database_url=fresh_db_url)) as client: - assert client.get("/api/products/images/1/detail").status_code == 401 - - -def test_bad_rendition_is_422(fresh_db_url): - with _merchant_client(fresh_db_url) as client: - _sf, iid, _keys = _seed_image(fresh_db_url, status="fetched", - with_detail_bytes=b"x", store=client.app.state.objectstore) - assert client.get(f"/api/products/images/{iid}/huge").status_code == 422 diff --git a/backend/tests/test_products_imagefetch.py b/backend/tests/test_products_imagefetch.py deleted file mode 100644 index ef9c3eb..0000000 --- a/backend/tests/test_products_imagefetch.py +++ /dev/null @@ -1,159 +0,0 @@ -"""image-fetch phase: SSRF guard, fetch+process+store, run completion, resume (§6.5.4/§6.9).""" -import io -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer - -import psycopg -import pytest -from PIL import Image - -from app.domains.products import imagefetch, repo -from app.platform import db, objectstore - - -def _png(w, h): - buf = io.BytesIO(); Image.new("RGB", (w, h), (1, 2, 3)).save(buf, "PNG"); return buf.getvalue() - - -class _Host(BaseHTTPRequestHandler): - GOOD = _png(800, 800) - TINY = _png(64, 64) - def log_message(self, *a): pass - def do_GET(self): - if self.path.startswith("/good.png"): - self.send_response(200); self.send_header("Content-Type", "image/png") - self.end_headers(); self.wfile.write(self.GOOD) - elif self.path.startswith("/tiny.png"): - self.send_response(200); self.send_header("Content-Type", "image/png") - self.end_headers(); self.wfile.write(self.TINY) - else: - self.send_response(404); self.end_headers() - - -@pytest.fixture() -def host(): - srv = HTTPServer(("127.0.0.1", 0), _Host) - t = threading.Thread(target=srv.serve_forever, daemon=True); t.start() - yield f"http://127.0.0.1:{srv.server_address[1]}" - srv.shutdown() - - -@pytest.fixture() -def pool(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn); conn.commit() - p = db.open_pool(fresh_db_url, max_size=6) - yield p - p.close() - - -def _seed(pool): - """account+storefront+run; returns (storefront_id, account_id, run_id).""" - with pool.connection() as conn: - acct = conn.execute("INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0] - sf = conn.execute("INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0] - conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf)) - rid = conn.execute( - "INSERT INTO import_run (storefront_id, account_id, file_name, dialect," - " products_added, products_updated, rows_errored, status)" - " VALUES (%s,%s,'c.csv','canonical',0,0,0,'fetching_images') RETURNING id", (sf, acct)).fetchone()[0] - conn.commit() - return sf, acct, rid - - -def _product(pool, sf, handle): - with pool.connection() as conn: - pid = conn.execute("INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id", - (sf, handle, handle.title())).fetchone()[0] - conn.commit() - return pid - - -def _image(pool, pid, url, rid, position=1): - with pool.connection() as conn: - iid = conn.execute("INSERT INTO product_image (product_id, source_url, position, status, import_run_id)" - " VALUES (%s,%s,%s,'pending',%s) RETURNING id", (pid, url, position, rid)).fetchone()[0] - conn.commit() - return iid - - -def _img_row(pool, iid): - with pool.connection() as conn: - r = conn.execute("SELECT status, key_original, key_thumb, key_card, key_detail FROM product_image WHERE id=%s", - (iid,)).fetchone() - return {"status": r[0], "key_original": r[1], "key_thumb": r[2], "key_card": r[3], "key_detail": r[4]} - - -def _run_status(pool, rid): - with pool.connection() as conn: - return conn.execute("SELECT status FROM import_run WHERE id=%s", (rid,)).fetchone()[0] - - -def test_ssrf_guard_rejects_private_when_disallowed(): - with pytest.raises(imagefetch.FetchBlocked): - imagefetch.fetch_bytes("http://127.0.0.1:1/x.png", allow_private=False) - with pytest.raises(imagefetch.FetchBlocked): - imagefetch.fetch_bytes("http://169.254.169.254/latest/meta-data", allow_private=False) - with pytest.raises(imagefetch.FetchBlocked): - imagefetch.fetch_bytes("ftp://example.com/x", allow_private=False) - - -def test_ssrf_guard_allows_private_when_enabled(host): - data, ctype = imagefetch.fetch_bytes(f"{host}/good.png", allow_private=True) - assert ctype.startswith("image/") and len(data) > 0 - - -def test_run_phase_classifies_each_image(pool, host, tmp_path): - store = objectstore.LocalObjectStore(str(tmp_path)) - sf, _acct, rid = _seed(pool) - p = _product(pool, sf, "lamp") - ok = _image(pool, p, f"{host}/good.png", rid, position=1) - _image(pool, p, f"{host}/tiny.png", rid, position=2) - _image(pool, p, f"{host}/missing.png", rid, position=3) - imagefetch.run_image_phase(pool, store, rid, allow_private=True) - with pool.connection() as conn: - counts = repo.run_image_counts(conn, rid) - assert counts["fetched"] == 1 and counts["rejected"] == 1 and counts["failed"] == 1 - row = _img_row(pool, ok) - assert row["status"] == "fetched" - for k in ("key_original", "key_thumb", "key_card", "key_detail"): - assert row[k] and store.get(row[k]) - assert _run_status(pool, rid) == "complete_with_problems" - - -def test_unexpected_processing_error_marks_image_failed_not_wedged(pool, host, tmp_path, monkeypatch): - # If anything after the fetch raises unexpectedly (e.g. objectstore.put), - # the image is marked failed and the run still reaches a terminal status — - # never stranded in fetching_images. - store = objectstore.LocalObjectStore(str(tmp_path)) - sf, _acct, rid = _seed(pool) - p = _product(pool, sf, "lamp") - iid = _image(pool, p, f"{host}/good.png", rid, position=1) - - def _boom(*a, **k): - raise RuntimeError("storage down") - monkeypatch.setattr(store, "put", _boom) - - imagefetch.run_image_phase(pool, store, rid, allow_private=True) - with pool.connection() as conn: - counts = repo.run_image_counts(conn, rid) - assert counts["failed"] == 1 and counts["pending"] == 0 - assert _run_status(pool, rid) == "complete_with_problems" - row = _img_row(pool, iid) - assert row["status"] == "failed" - - -def test_resume_after_kill_completes_remaining(pool, host, tmp_path): - store = objectstore.LocalObjectStore(str(tmp_path)) - sf, _acct, rid = _seed(pool) - p = _product(pool, sf, "lamp") - a = _image(pool, p, f"{host}/good.png", rid, position=1) - with pool.connection() as conn: # simulate crash: one already fetched, run still fetching_images - repo.mark_image_fetched(conn, a, {"original": "o", "thumb": "t", "card": "c", "detail": "d"}) - conn.commit() - _image(pool, p, f"{host}/good.png?2", rid, position=2) # still pending - resumed = imagefetch.recover_incomplete_runs(pool, store, allow_private=True) - assert rid in resumed - with pool.connection() as conn: - assert repo.run_image_counts(conn, rid)["pending"] == 0 - assert _run_status(pool, rid) == "complete" diff --git a/backend/tests/test_products_invariants.py b/backend/tests/test_products_invariants.py deleted file mode 100644 index 7e045cb..0000000 --- a/backend/tests/test_products_invariants.py +++ /dev/null @@ -1,101 +0,0 @@ -"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed), -apply transactionality (§6.8), TEL-6.""" -import json -import logging - -import psycopg -import pytest - -from app.domains import products -from app.domains.products import repo, service -from app.platform import db - -CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n" -CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n" - - -@pytest.fixture() -def migrated_conn(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn) - yield conn - - -def _merchant(conn, email="m@example.com", shop="Shop"): - acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0] - sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0] - conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf)) - conn.commit() - return acct, sf - - -def _import(conn, acct, sf, data): - d = products.import_validate(conn, sf, acct, "f.csv", data) - return products.confirm_draft(conn, sf, acct, d["id"]) - - -def test_inv10_partial_file_never_deletes(migrated_conn): - acct, sf = _merchant(migrated_conn) - _import(migrated_conn, acct, sf, CSV_A) - before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] - _import(migrated_conn, acct, sf, CSV_PARTIAL) - after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] - assert after >= before == 2 - - -def test_inv14_two_storefronts_zero_bleed(migrated_conn): - acct1, sf1 = _merchant(migrated_conn) - acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other") - _import(migrated_conn, acct1, sf1, CSV_A) - assert products.summary(migrated_conn, sf2)["product_count"] == 0 - assert products.list_runs(migrated_conn, sf2) == [] - _import(migrated_conn, acct2, sf2, CSV_A) - assert products.summary(migrated_conn, sf2)["product_count"] == 2 - run1 = products.list_runs(migrated_conn, sf1)[0] - with pytest.raises(products.RunNotFound): - products.get_run(migrated_conn, sf2, run1["id"]) - - -def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog): - acct, sf = _merchant(migrated_conn) - d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A) - - def boom(*a, **k): - raise RuntimeError("mid-apply crash") - monkeypatch.setattr(service.repo, "insert_run_errors", boom) - lg = logging.getLogger("ecomm") - prior = lg.propagate - lg.propagate = True - try: - with caplog.at_level(logging.INFO, logger="ecomm.telemetry"): - with pytest.raises(RuntimeError): - products.confirm_draft(migrated_conn, sf, acct, d["id"]) - finally: - lg.propagate = prior - assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0 - assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0 - assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1 - events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"] - assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events) - - -# A partial Shopify export (signature: Body (HTML) + Variant Grams) naming only one -# of the two existing products — INV-10 must hold across the dialect boundary. -SHOPIFY_PARTIAL = ( - b"Handle,Title,Body (HTML),Variant Price,Variant Grams\n" - b"mug,Mug,

x

,12.00,180\n" -) - - -def test_inv10_shopify_partial_never_deletes(migrated_conn): - acct, sf = _merchant(migrated_conn) - _import(migrated_conn, acct, sf, CSV_A) - before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] - d = products.import_validate(migrated_conn, sf, acct, "shopify.csv", SHOPIFY_PARTIAL) - assert d["dialect"] == "shopify" - products.confirm_draft(migrated_conn, sf, acct, d["id"]) - after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] - # INV-10: nothing deleted; the unmentioned 'tee' is untouched. - assert after >= before == 2 - handles = {r[0] for r in migrated_conn.execute("SELECT handle FROM product").fetchall()} - assert {"mug", "tee"} <= handles diff --git a/backend/tests/test_products_repo_images.py b/backend/tests/test_products_repo_images.py deleted file mode 100644 index 0f28ee0..0000000 --- a/backend/tests/test_products_repo_images.py +++ /dev/null @@ -1,93 +0,0 @@ -"""products repo — image-phase helpers (SD-0002 §6.5.4).""" -import psycopg -import pytest - -from app.domains.products import repo -from app.platform import db - - -@pytest.fixture() -def migrated_conn(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn) - yield conn - - -@pytest.fixture() -def merchant(migrated_conn): - acct = migrated_conn.execute( - "INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0] - sf = migrated_conn.execute( - "INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0] - migrated_conn.execute( - "INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf)) - migrated_conn.commit() - return {"account_id": acct, "storefront_id": sf} - - -def _run(conn, m, status="fetching_images"): - return conn.execute( - "INSERT INTO import_run (storefront_id, account_id, file_name, dialect," - " products_added, products_updated, rows_errored, status)" - " VALUES (%s,%s,'c.csv','canonical',0,0,0,%s) RETURNING id", - (m["storefront_id"], m["account_id"], status)).fetchone()[0] - - -def _product(conn, m, handle): - return conn.execute( - "INSERT INTO product (storefront_id, handle, title) VALUES (%s,%s,%s) RETURNING id", - (m["storefront_id"], handle, handle.title())).fetchone()[0] - - -def _image(conn, product_id, url, run_id, status="pending", position=1): - return conn.execute( - "INSERT INTO product_image (product_id, source_url, position, status, import_run_id)" - " VALUES (%s,%s,%s,%s,%s) RETURNING id", - (product_id, url, position, status, run_id)).fetchone()[0] - - -def test_pending_images_for_run_returns_only_pending(migrated_conn, merchant): - rid = _run(migrated_conn, merchant) - p = _product(migrated_conn, merchant, "lamp") - a = _image(migrated_conn, p, "https://m/a.png", rid, status="pending", position=1) - _image(migrated_conn, p, "https://m/b.png", rid, status="fetched", position=2) - pend = repo.pending_images_for_run(migrated_conn, rid) - assert [img["id"] for img in pend] == [a] - assert pend[0]["source_url"] == "https://m/a.png" - assert pend[0]["storefront_id"] == merchant["storefront_id"] - - -def test_claim_image_for_fetch_is_idempotent(migrated_conn, merchant): - rid = _run(migrated_conn, merchant) - p = _product(migrated_conn, merchant, "lamp") - img = _image(migrated_conn, p, "https://m/a.png", rid) - assert repo.claim_image_for_fetch(migrated_conn, img) is True - repo.mark_image_fetched(migrated_conn, img, - {"original": "o", "thumb": "t", "card": "c", "detail": "d"}) - assert repo.claim_image_for_fetch(migrated_conn, img) is False - - -def test_mark_image_outcomes_and_run_counts(migrated_conn, merchant): - rid = _run(migrated_conn, merchant) - p = _product(migrated_conn, merchant, "lamp") - ok = _image(migrated_conn, p, "https://m/a.png", rid, position=1) - bad = _image(migrated_conn, p, "https://m/b.png", rid, position=2) - miss = _image(migrated_conn, p, "https://m/c.png", rid, position=3) - repo.mark_image_fetched(migrated_conn, ok, {"original": "o", "thumb": "t", "card": "c", "detail": "d"}) - repo.mark_image_rejected(migrated_conn, bad, "rejected_low_res", "below the resolution bar") - repo.mark_image_failed(migrated_conn, miss, "host unreachable") - assert repo.run_image_counts(migrated_conn, rid) == { - "fetched": 1, "rejected": 1, "failed": 1, "pending": 0, "total": 3} - outcomes = repo.run_image_outcomes(migrated_conn, rid) - assert {o["handle"] for o in outcomes} == {"lamp"} - assert {o["outcome"] for o in outcomes} == {"rejected_low_res", "failed"} # fetched not listed - - -def test_set_run_status_and_incomplete_runs(migrated_conn, merchant): - rid = _run(migrated_conn, merchant, status="fetching_images") - _run(migrated_conn, merchant, status="complete") - assert rid in [r["id"] for r in repo.incomplete_runs(migrated_conn)] - repo.set_run_status(migrated_conn, rid, "complete") - assert rid not in [r["id"] for r in repo.incomplete_runs(migrated_conn)] - row = migrated_conn.execute("SELECT status, completed_at FROM import_run WHERE id=%s", (rid,)).fetchone() - assert row[0] == "complete" and row[1] is not None diff --git a/backend/tests/test_products_service.py b/backend/tests/test_products_service.py deleted file mode 100644 index 335d60e..0000000 --- a/backend/tests/test_products_service.py +++ /dev/null @@ -1,242 +0,0 @@ -"""products service — drafts: validate/preview/discard (PUC-2/3/3a/5a; INV-11).""" -import json -import logging - -import psycopg -import pytest - -from app.domains import products -from app.platform import db - -GOOD_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,18.00\nstar-tee,Star Tee,Acme,24.00\n" - - -@pytest.fixture() -def migrated_conn(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn) - yield conn - - -@pytest.fixture() -def merchant(migrated_conn): - acct = migrated_conn.execute( - "INSERT INTO account (email) VALUES ('m@example.com') RETURNING id").fetchone()[0] - sf = migrated_conn.execute( - "INSERT INTO storefront (name) VALUES ('Shop') RETURNING id").fetchone()[0] - migrated_conn.execute( - "INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf)) - migrated_conn.commit() - return {"account_id": acct, "storefront_id": sf} - - -def test_import_validate_creates_draft_with_summary(migrated_conn, merchant): - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - assert draft["dialect"] == "canonical" - assert draft["summary"] == {"adds": 2, "updates": 0, "unchanged": 0, "errors": 0} - assert draft["expires_at"] - - -def test_validate_writes_nothing_to_catalog_inv11(migrated_conn, merchant): - products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0 - assert migrated_conn.execute("SELECT count(*) FROM variant").fetchone()[0] == 0 - - -def test_file_rejection_leaves_no_draft(migrated_conn, merchant): - with pytest.raises(products.FileRejected): - products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "bad.csv", - b"Vendor,Price\nAcme,1\n") - assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0 - - -def test_records_paging_and_kind_filter(migrated_conn, merchant): - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - recs = products.get_draft_records(migrated_conn, merchant["storefront_id"], draft["id"]) - assert [r["handle"] for r in recs] == ["moon-mug", "star-tee"] - adds = products.get_draft_records( - migrated_conn, merchant["storefront_id"], draft["id"], kind="add", limit=1) - assert len(adds) == 1 and adds[0]["kind"] == "add" - - -def test_discard_deletes_no_trace_puc3a(migrated_conn, merchant): - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) - assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0 - products.discard_draft(migrated_conn, merchant["storefront_id"], draft["id"]) # idempotent - - -def test_draft_scoped_to_storefront_inv14(migrated_conn, merchant): - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - other_sf = migrated_conn.execute( - "INSERT INTO storefront (name) VALUES ('Other') RETURNING id").fetchone()[0] - migrated_conn.commit() - with pytest.raises(products.DraftNotFound): - products.get_draft(migrated_conn, other_sf, draft["id"]) - - -def test_expired_draft_raises_and_lazily_deletes(migrated_conn, merchant): - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - migrated_conn.execute( - "UPDATE import_draft SET expires_at = now() - interval '1 minute' WHERE id = %s", - (draft["id"],)) - migrated_conn.commit() - with pytest.raises(products.DraftExpired): - products.get_draft(migrated_conn, merchant["storefront_id"], draft["id"]) - assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0 - - -@pytest.fixture() -def telemetry_propagation(): - """create_app() sets propagate=False on the parent "ecomm" logger - (main._ensure_app_logging), which hides ecomm.telemetry records from caplog's - root-logger handler whenever an API test ran first. Restore propagation here.""" - lg = logging.getLogger("ecomm") - prior = lg.propagate - lg.propagate = True - yield - lg.propagate = prior - - -def test_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation): - with caplog.at_level(logging.INFO, logger="ecomm.telemetry"): - products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "cat.csv", GOOD_CSV) - events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"] - assert any( - e["event"] == "import_draft_created" and e["adds"] == 2 and e["row_count"] == 2 - and "duration_ms" in e and e["unknown_columns_count"] == 0 - for e in events - ) - - -UPDATE_CSV = b"Handle,Title,Vendor,Variant Price\nmoon-mug,Moon Mug,Acme,21.00\nstar-tee,Star Tee,Acme,24.00\n" -MIXED_CSV = b"Handle,Title,Variant Price\ngood-mug,Mug,10.00\nbad-tee,Tee,not-a-price\n" - - -def _validate(conn, m, data=GOOD_CSV): - return products.import_validate(conn, m["storefront_id"], m["account_id"], "cat.csv", data) - - -def test_confirm_applies_adds_and_records_run(migrated_conn, merchant): - draft = _validate(migrated_conn, merchant) - run_id = products.confirm_draft( - migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 - run = products.get_run(migrated_conn, merchant["storefront_id"], run_id) - assert run["products_added"] == 2 and run["status"] == "complete" - assert run["by"] == "m@example.com" - assert run["image_progress"] == {"done": 0, "total": 0} and run["image_outcomes"] == [] - assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 0 - - -def test_confirm_update_changes_only_diffed_fields(migrated_conn, merchant): - d1 = _validate(migrated_conn, merchant) - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"]) - d2 = _validate(migrated_conn, merchant, UPDATE_CSV) - assert d2["summary"] == {"adds": 0, "updates": 1, "unchanged": 1, "errors": 0} - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"]) - price = migrated_conn.execute( - "SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'" - ).fetchone()[0] - assert str(price) == "21.00" - assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 2 # no dupes (BUC-3) - - -def test_confirm_blank_position_cell_round_trips(migrated_conn, merchant): - # A present-but-empty Variant Position cell resolves to file order at diff - # time — never SET position = NULL (which would abort the confirm on the - # NOT NULL constraint). - d1 = _validate(migrated_conn, merchant) - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"]) - blank_position_csv = ( - b"Handle,Title,Vendor,Variant Price,Variant Position\n" - b"moon-mug,Moon Mug,Acme,21.00,\n" - b"star-tee,Star Tee,Acme,24.00,\n" - ) - d2 = _validate(migrated_conn, merchant, blank_position_csv) - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"]) - price = migrated_conn.execute( - "SELECT v.price FROM variant v JOIN product p ON p.id = v.product_id WHERE p.handle='moon-mug'" - ).fetchone()[0] - assert str(price) == "21.00" - - -def test_confirm_mixed_applies_valid_records_errors(migrated_conn, merchant): - draft = _validate(migrated_conn, merchant, MIXED_CSV) - run_id = products.confirm_draft( - migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 1 - run = products.get_run(migrated_conn, merchant["storefront_id"], run_id) - assert run["rows_errored"] == 1 - assert run["errors"][0]["column"] == "Variant Price" - - -def test_confirm_stale_fingerprint_409_inv11(migrated_conn, merchant): - draft = _validate(migrated_conn, merchant) - other = _validate(migrated_conn, merchant) - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], other["id"]) - with pytest.raises(products.PreviewStale): - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - - -def test_confirm_nothing_to_apply_puc10(migrated_conn, merchant): - d1 = _validate(migrated_conn, merchant) - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"]) - d2 = _validate(migrated_conn, merchant) - assert d2["summary"]["unchanged"] == 2 - with pytest.raises(products.NothingToApply): - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"]) - - -def test_runs_history_newest_first(migrated_conn, merchant): - d1 = _validate(migrated_conn, merchant) - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d1["id"]) - d2 = _validate(migrated_conn, merchant, UPDATE_CSV) - r2 = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d2["id"]) - runs = products.list_runs(migrated_conn, merchant["storefront_id"]) - assert [r["id"] for r in runs][0] == r2 - - -def test_summary_counts(migrated_conn, merchant): - assert products.summary(migrated_conn, merchant["storefront_id"]) == { - "product_count": 0, "image_problem_count": 0, "latest_run_id": None} - d = _validate(migrated_conn, merchant) - rid = products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], d["id"]) - s = products.summary(migrated_conn, merchant["storefront_id"]) - assert s == {"product_count": 2, "image_problem_count": 0, "latest_run_id": rid} - - -def test_tel2_emitted_on_confirm(migrated_conn, merchant, caplog, telemetry_propagation): - draft = _validate(migrated_conn, merchant) - with caplog.at_level(logging.INFO, logger="ecomm.telemetry"): - products.confirm_draft(migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"] - assert any(e["event"] == "import_run_completed" and e["added"] == 2 for e in events) - - -def test_confirm_marks_run_fetching_images_when_images_present(migrated_conn, merchant): - csv = b"Handle,Title,Image Src\nlamp,Lamp,https://m.example/a.png\n" - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv) - run_id = products.confirm_draft( - migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - run = products.get_run(migrated_conn, merchant["storefront_id"], run_id) - assert run["status"] == "fetching_images" - assert run["image_progress"]["total"] >= 1 - - -def test_confirm_marks_run_complete_when_no_images(migrated_conn, merchant): - csv = b"Handle,Title,Vendor\nlamp,Lamp,Acme\n" - draft = products.import_validate( - migrated_conn, merchant["storefront_id"], merchant["account_id"], "c.csv", csv) - run_id = products.confirm_draft( - migrated_conn, merchant["storefront_id"], merchant["account_id"], draft["id"]) - assert products.get_run(migrated_conn, merchant["storefront_id"], run_id)["status"] == "complete" diff --git a/backend/tests/test_static_spa.py b/backend/tests/test_static_spa.py deleted file mode 100644 index e07a643..0000000 --- a/backend/tests/test_static_spa.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Deployed topology (launch-app SPEC §2): nginx proxies EVERYTHING to the backend, so -the backend must serve the built SPA (frontend/dist) itself. Dev is unaffected — Vite -serves the frontend and the default dist dir simply doesn't exist.""" -from fastapi.testclient import TestClient - -from app.main import create_app - - -def test_spa_served_when_dist_present(fresh_db_url, tmp_path): - (tmp_path / "index.html").write_text("ecomm spa") - 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" diff --git a/backend/tests/test_storefronts_create.py b/backend/tests/test_storefronts_create.py deleted file mode 100644 index 1160a69..0000000 --- a/backend/tests/test_storefronts_create.py +++ /dev/null @@ -1,154 +0,0 @@ -"""SLICE-3 storefront creation — service + endpoint scenario tests (SD-0001 §6.5 PUC-4/7).""" -import re -from contextlib import contextmanager - -import psycopg -import pytest -from fastapi.testclient import TestClient - -from app.main import create_app -from app.domains import storefronts -from app.platform import db - - -@pytest.fixture() -def migrated_conn(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn) - yield conn - - -def _account_id(conn, email="merchant@example.com"): - return conn.execute( - "INSERT INTO account (email) VALUES (%s) RETURNING id", (email,) - ).fetchone()[0] - - -def test_create_storefront_with_name(migrated_conn): - acct = _account_id(migrated_conn) - sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Ben's Bets") - assert sf.name == "Ben's Bets" - assert storefronts.storefront_for(migrated_conn, acct) == sf - - -def test_14_01_0026_blank_name_gets_generated_default(migrated_conn): - acct = _account_id(migrated_conn) - sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None) - assert sf.name == "merchant's storefront" - - -def test_whitespace_name_treated_as_blank(migrated_conn): - acct = _account_id(migrated_conn) - sf = storefronts.create_storefront(migrated_conn, acct, "ben@bensbets.com", " ") - assert sf.name == "ben's storefront" - - -def test_second_create_refused_inv_4(migrated_conn): - acct = _account_id(migrated_conn) - storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "First") - with pytest.raises(storefronts.AlreadyOwnsStorefront): - storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", "Second") - - -def test_membership_row_is_owner(migrated_conn): - acct = _account_id(migrated_conn) - sf = storefronts.create_storefront(migrated_conn, acct, "merchant@example.com", None) - role = migrated_conn.execute( - "SELECT role FROM storefront_membership WHERE account_id = %s AND storefront_id = %s", - (acct, sf.id), - ).fetchone()[0] - assert role == "owner" - - -def test_storefront_for_none_when_absent(migrated_conn): - acct = _account_id(migrated_conn) - assert storefronts.storefront_for(migrated_conn, acct) is None - - -# ── endpoint scenario tests (§6.4 POST /api/storefronts; corpus 14.01.*) ────────── - - -@contextmanager -def _signed_in_client(fresh_db_url, email="merchant@example.com"): - with TestClient(create_app(database_url=fresh_db_url)) as client: - client.post("/api/auth/request-code", json={"email": email}) - code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) - client.post("/api/auth/verify", json={"email": email, "code": code}) - yield client - - -def test_14_01_0013_create_storefront_with_name(fresh_db_url): - with _signed_in_client(fresh_db_url) as client: - resp = client.post("/api/storefronts", json={"name": "Ben's Bets"}) - assert resp.status_code == 201 - assert resp.json()["name"] == "Ben's Bets" - assert isinstance(resp.json()["id"], int) - - -def test_14_01_0015_0016_nothing_but_a_name_is_asked(fresh_db_url): - # No payment method, plan, or commitment: the request body needs nothing at all and - # the response carries only the storefront — no plan/trial/billing fields. - with _signed_in_client(fresh_db_url) as client: - resp = client.post("/api/storefronts", json={}) - assert resp.status_code == 201 - assert set(resp.json().keys()) == {"id", "name"} - - -def test_14_01_0025_create_lands_on_admin(fresh_db_url): - # After create, the entry-routing answer carries the storefront -> admin (PUC-6). - with _signed_in_client(fresh_db_url) as client: - created = client.post("/api/storefronts", json={"name": "Ben's Bets"}).json() - me = client.get("/api/auth/me").json() - assert me["storefront"] == created - - -def test_puc_05_returning_without_storefront_routes_to_create(fresh_db_url): - with _signed_in_client(fresh_db_url) as client: - me = client.get("/api/auth/me").json() - assert me["storefront"] is None - - -def test_14_01_0027_returning_with_storefront_straight_to_admin(fresh_db_url): - # PUC-6: a returning merchant's verify response already carries the storefront. - from datetime import datetime, timedelta, timezone - - email = "returning@example.com" - with _signed_in_client(fresh_db_url, email) as client: - client.post("/api/storefronts", json={"name": "Ben's Bets"}) - # fresh login: backdate the consumed code to clear the 60s resend cooldown - with psycopg.connect(fresh_db_url) as c: - c.execute( - "UPDATE auth_code SET created_at = %s WHERE email = %s", - (datetime.now(timezone.utc) - timedelta(minutes=2), email), - ) - c.commit() - client.post("/api/auth/request-code", json={"email": email}) - code = re.search(r"\b(\d{6})\b", client.app.state.mailer.outbox[-1].body).group(1) - resp = client.post("/api/auth/verify", json={"email": email, "code": code}) - assert resp.status_code == 200 - assert resp.json()["storefront"]["name"] == "Ben's Bets" - assert resp.json()["created"] is False - - -def test_puc_07_second_create_refused_409(fresh_db_url): - with _signed_in_client(fresh_db_url) as client: - client.post("/api/storefronts", json={"name": "First"}) - resp = client.post("/api/storefronts", json={"name": "Second"}) - assert resp.status_code == 409 - assert resp.json()["error"]["code"] == "already_owns_storefront" - - -def test_puc_08_admin_shell_answer(fresh_db_url): - # The shell renders from /me alone: storefront name + signed-in email (§6.5 PUC-8). - with _signed_in_client(fresh_db_url) as client: - client.post("/api/storefronts", json={"name": "Ben's Bets"}) - me = client.get("/api/auth/me").json() - assert me["account"]["email"] == "merchant@example.com" - assert me["storefront"]["name"] == "Ben's Bets" - - -def test_create_storefront_requires_session(fresh_db_url): - with TestClient(create_app(database_url=fresh_db_url)) as client: - resp = client.post("/api/storefronts", json={"name": "Nope"}) - assert resp.status_code == 401 - assert resp.json()["error"]["code"] == "unauthenticated" diff --git a/backend/tests/test_storefronts_invariants.py b/backend/tests/test_storefronts_invariants.py deleted file mode 100644 index 6ce0e60..0000000 --- a/backend/tests/test_storefronts_invariants.py +++ /dev/null @@ -1,64 +0,0 @@ -"""INV-4's sharp edges (SD-0001 §6.8): concurrent second-storefront refusal, and the -membership schema staying many-capable (the rule is a deletable guard, not a schema law).""" -import threading - -import psycopg -import pytest - -from app.domains import storefronts -from app.platform import db - - -@pytest.fixture() -def migrated_url(fresh_db_url): - with psycopg.connect(fresh_db_url) as conn: - db.migrate(conn) - return fresh_db_url - - -def test_inv_4_concurrent_creates_exactly_one_wins(migrated_url): - with psycopg.connect(migrated_url) as setup: - acct = setup.execute( - "INSERT INTO account (email) VALUES ('racer@example.com') RETURNING id" - ).fetchone()[0] - setup.commit() - - barrier = threading.Barrier(2) - results: list[str] = [] - - def racer(): - with psycopg.connect(migrated_url) as conn: - barrier.wait() - try: - storefronts.create_storefront(conn, acct, "racer@example.com", None) - results.append("created") - except storefronts.AlreadyOwnsStorefront: - results.append("refused") - - threads = [threading.Thread(target=racer) for _ in range(2)] - for t in threads: - t.start() - for t in threads: - t.join() - - assert sorted(results) == ["created", "refused"] - with psycopg.connect(migrated_url) as check: - count = check.execute( - "SELECT count(*) FROM storefront_membership WHERE account_id = %s", (acct,) - ).fetchone()[0] - assert count == 1 - - -def test_inv_4_schema_stays_many_capable(migrated_url): - # No UNIQUE(account_id) anywhere in the storefront path: the only unique constraint on - # storefront_membership is its composite PK, so many-per-account stays a deletable - # service rule (R-5 mitigation). - with psycopg.connect(migrated_url) as conn: - uniques = conn.execute( - "SELECT i.indkey::text FROM pg_index i" - " JOIN pg_class c ON c.oid = i.indrelid" - " WHERE c.relname = 'storefront_membership' AND (i.indisunique OR i.indisprimary)" - ).fetchall() - # exactly one unique index (the composite PK over two columns) - assert len(uniques) == 1 - assert len(uniques[0][0].split()) == 2 diff --git a/deployment.toml b/deployment.toml index 865f27c..af0de76 100644 --- a/deployment.toml +++ b/deployment.toml @@ -35,8 +35,9 @@ ECOMM_SMTP_HOST = "smtp.gmail.com" ECOMM_SMTP_PORT = "587" ECOMM_SMTP_USER = "ben.stull@wiggleverse.org" ECOMM_SMTP_FROM = "ecomm " -ECOMM_OBJECTSTORE_KIND = "gcs" -ECOMM_OBJECTSTORE_BUCKET = "wiggleverse-ecomm-ppe-media" +# Objectstore (GCS) env removed in the network-seed reset — the seed no longer reads it. +# Re-add when network catalog-image storage lands; bucket wiggleverse-ecomm-ppe-media stays +# provisioned. [secrets] # REFERENCES only — never bytes (§8.3) ECOMM_SESSION_SECRET = "wiggleverse-ecomm/ecomm-ppe-session-secret" diff --git a/e2e/.gitignore b/e2e/.gitignore deleted file mode 100644 index 1fcd284..0000000 --- a/e2e/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -.backend.log -.objectstore/ -test-results/ -playwright-report/ diff --git a/e2e/fixtures/good.csv b/e2e/fixtures/good.csv deleted file mode 100644 index 5c54ca4..0000000 --- a/e2e/fixtures/good.csv +++ /dev/null @@ -1,4 +0,0 @@ -Handle,Title,Vendor,Tags,Status,Published,Option1 Name,Option1 Value,Variant SKU,Variant Price,Variant Inventory Qty -moon-mug,Moon Mug,Wiggle Goods,"kitchen, mugs",active,TRUE,,,WG-MUG-001,18.00,40 -star-tee,Star Tee,Wiggle Goods,apparel,active,TRUE,Size,S,WG-TEE-S,24.00,12 -star-tee,,,,,,,M,WG-TEE-M,24.00,18 diff --git a/e2e/fixtures/images/good.png b/e2e/fixtures/images/good.png deleted file mode 100644 index 0a50ae4..0000000 Binary files a/e2e/fixtures/images/good.png and /dev/null differ diff --git a/e2e/fixtures/images/tiny.png b/e2e/fixtures/images/tiny.png deleted file mode 100644 index 293a859..0000000 Binary files a/e2e/fixtures/images/tiny.png and /dev/null differ diff --git a/e2e/fixtures/missing-title.csv b/e2e/fixtures/missing-title.csv deleted file mode 100644 index e4e06e4..0000000 --- a/e2e/fixtures/missing-title.csv +++ /dev/null @@ -1,2 +0,0 @@ -Handle,Vendor -mug,Acme diff --git a/e2e/fixtures/mixed-errors.csv b/e2e/fixtures/mixed-errors.csv deleted file mode 100644 index 4b260bd..0000000 --- a/e2e/fixtures/mixed-errors.csv +++ /dev/null @@ -1,4 +0,0 @@ -Handle,Title,Variant Price -good-mug,Good Mug,10.00 -bad-tee,Bad Tee,not-a-price -also-good,Also Good,5.00 diff --git a/e2e/fixtures/shopify-export.csv b/e2e/fixtures/shopify-export.csv deleted file mode 100644 index 2a3ac4c..0000000 --- a/e2e/fixtures/shopify-export.csv +++ /dev/null @@ -1,2 +0,0 @@ -Handle,Title,Body (HTML),Vendor,Type,Tags,Published,Status,Variant SKU,Variant Grams,Variant Inventory Qty,Variant Price,Variant Compare At Price,Gift Card,SEO Title,Cost per item -shopify-mug,Shopify Mug,

Imported from Shopify.

,Acme,Drinkware,"mugs",TRUE,active,SM-001,300,25,14.00,20.00,FALSE,Shopify Mug | Acme,7.00 diff --git a/e2e/fixtures/with-images.csv b/e2e/fixtures/with-images.csv deleted file mode 100644 index 4677cac..0000000 --- a/e2e/fixtures/with-images.csv +++ /dev/null @@ -1,4 +0,0 @@ -Handle,Title,Image Src -good-lamp,Good Lamp,http://127.0.0.1:8799/good.png -tiny-lamp,Tiny Lamp,http://127.0.0.1:8799/tiny.png -gone-lamp,Gone Lamp,http://127.0.0.1:8799/missing.png diff --git a/e2e/helpers.ts b/e2e/helpers.ts deleted file mode 100644 index fec4f28..0000000 --- a/e2e/helpers.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Shared E2E helpers — the sign-up journey (SD-0001 §5.1–§5.4) and products-page -// navigation (SD-0002 §5.2). Selectors are role/label-based against the real screens -// (Landing.tsx, SignIn.tsx, CreateStorefront.tsx, Admin.tsx, ProductsPage.tsx). -import { expect, type Page } from "@playwright/test"; -import { readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const LOG = join(__dirname, ".backend.log"); -let seq = 0; - -// The storefront name every helper-created merchant uses; tests assert against it. -export const STOREFRONT_NAME = "E2E Test Goods"; - -export function freshEmail(): string { - return `merchant${Date.now()}-${seq++}@example.com`; -} - -// LogMailer's exact line (backend/app/platform/mailer.py): -// INFO:ecomm.mailer: LogMailer -> | Your ecomm code: <6 digits>\n -// The first 6-digit group after the marker is the code. -async function codeFor(email: string): Promise { - 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 { - // 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
menu, then click the status option, capturing the download. - // The menu is a native
that *toggles* on each summary click and is - // NOT closed by clicking a download (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 }; -} diff --git a/e2e/image-host.mjs b/e2e/image-host.mjs deleted file mode 100644 index c8ef1ac..0000000 --- a/e2e/image-host.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { createServer } from "node:http"; -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const dir = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "images"); -createServer(async (req, res) => { - const name = (req.url || "").replace(/^\//, "").split("?")[0]; - if (name === "good.png" || name === "tiny.png") { - try { - const buf = await readFile(join(dir, name)); - res.writeHead(200, { "Content-Type": "image/png" }); - res.end(buf); - return; - } catch { /* fallthrough */ } - } - res.writeHead(404); - res.end(); -}).listen(8799, "127.0.0.1"); diff --git a/e2e/package-lock.json b/e2e/package-lock.json deleted file mode 100644 index 4b6066b..0000000 --- a/e2e/package-lock.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "wiggleverse-ecomm-e2e", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "wiggleverse-ecomm-e2e", - "devDependencies": { - "@playwright/test": "^1.48.0" - } - }, - "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.60.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - } - } -} diff --git a/e2e/package.json b/e2e/package.json deleted file mode 100644 index c7a9766..0000000 --- a/e2e/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "wiggleverse-ecomm-e2e", - "private": true, - "scripts": { "test": "playwright test" }, - "devDependencies": { "@playwright/test": "^1.48.0" } -} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts deleted file mode 100644 index a809705..0000000 --- a/e2e/playwright.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "@playwright/test"; - -export default defineConfig({ - testDir: "./tests", - timeout: 60_000, - retries: 0, - // One shared backend + log file; parallel sign-ins would interleave codes. - workers: 1, - use: { baseURL: "http://localhost:8765" }, - webServer: { - command: "bash ./serve.sh", - url: "http://localhost:8765/healthz", - reuseExistingServer: false, - timeout: 120_000, - }, -}); diff --git a/e2e/serve.sh b/e2e/serve.sh deleted file mode 100755 index c2c1cee..0000000 --- a/e2e/serve.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# E2E server: fresh ecomm_e2e database, LogMailer (codes land in .backend.log), -# backend on :8765 serving the built SPA (the deployed topology, SD-0001 §6.2). -set -euo pipefail -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -if [ ! -f "$repo_root/frontend/dist/index.html" ]; then - ( cd "$repo_root/frontend" && npm run build ) -fi - -"$repo_root/.venv/bin/python" - <<'PY' -import psycopg -admin = psycopg.connect("postgresql://ecomm:ecomm@localhost:5432/postgres", autocommit=True) -admin.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='ecomm_e2e' AND pid <> pg_backend_pid()") -admin.execute("DROP DATABASE IF EXISTS ecomm_e2e") -admin.execute("CREATE DATABASE ecomm_e2e") -PY - -export ECOMM_DATABASE_URL="postgresql://ecomm:ecomm@localhost:5432/ecomm_e2e" -export ECOMM_MAILER=log - -# Image pipeline (SLICE-7): local objectstore + a fixture image host on :8799. -# ECOMM_IMAGE_FETCH_ALLOW_PRIVATE lets the SSRF guard reach 127.0.0.1 — TEST-ONLY, -# never set in the deployed overlay. The &-backgrounded node host is a child of -# this serve process; Playwright tears the whole webServer group down between runs. -export ECOMM_OBJECTSTORE_KIND=local -export ECOMM_OBJECTSTORE_DIR="$repo_root/e2e/.objectstore" -export ECOMM_IMAGE_FETCH_ALLOW_PRIVATE=1 -rm -rf "$repo_root/e2e/.objectstore" -node "$repo_root/e2e/image-host.mjs" & - -cd "$repo_root/backend" -exec "$repo_root/.venv/bin/python" -m uvicorn app.main:app --port 8765 \ - > "$repo_root/e2e/.backend.log" 2>&1 diff --git a/e2e/tests/export-download.spec.ts b/e2e/tests/export-download.spec.ts deleted file mode 100644 index 78ba518..0000000 --- a/e2e/tests/export-download.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -// DoD scenario e2e_export_download (SD-0002 §6.8): export downloads a canonical -// CSV and the status filter is respected. -import { expect, test } from "@playwright/test"; -import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers"; - -test("e2e_export_download", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - await importGoodCsv(page); - await gotoProducts(page); - - // Export "All products" → a canonical CSV with both handles. - const all = await exportCatalog(page, "All products"); - expect(all.text.split("\n")[0]).toContain("Handle,"); - expect(all.text).toContain("moon-mug"); - expect(all.text).toContain("star-tee"); - - // good.csv's products are active → "Active" exports both, "Draft" is empty/disabled-path. - const active = await exportCatalog(page, "Active"); - expect(active.text).toContain("moon-mug"); -}); diff --git a/e2e/tests/image-outcomes.spec.ts b/e2e/tests/image-outcomes.spec.ts deleted file mode 100644 index ce8736b..0000000 --- a/e2e/tests/image-outcomes.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { gotoProducts, importWithImages, signUpWithStorefront } from "../helpers"; - -test("e2e_image_outcomes: per-image outcomes, problem rows, notice band", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - await importWithImages(page); - // Run detail polls fetching_images → terminal; wait for the outcome summary. - await expect(page.getByText("1 fetched · 1 rejected · 1 failed")).toBeVisible({ timeout: 30_000 }); - // Problem images listed in the outcomes table. - await expect(page.getByText("tiny-lamp")).toBeVisible(); - await expect(page.getByText("gone-lamp")).toBeVisible(); - // Products page surfaces the aggregate notice. - await gotoProducts(page); - await expect(page.getByText(/products have image problems/)).toBeVisible(); -}); diff --git a/e2e/tests/import-cancel.spec.ts b/e2e/tests/import-cancel.spec.ts deleted file mode 100644 index 2264bb7..0000000 --- a/e2e/tests/import-cancel.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -// DoD scenario e2e_import_cancel_no_trace (SD-0002 §6.8): cancelling at the -// preview gate (PUC-3a) leaves no trace — no products, no run in the history. -import { expect, test } from "@playwright/test"; -import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers"; - -test("e2e_import_cancel_no_trace", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - await uploadFixture(page, "good.csv"); - - // The preview gate is up. - await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible(); - await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible(); - - await page.getByRole("button", { name: "Cancel" }).click(); - - // Lands back on Products — still the empty state, no run recorded. - await expect(page.getByRole("heading", { level: 1, name: "Products" })).toBeVisible(); - await expect( - page.getByText("No products yet. Bulk import is how product data gets in."), - ).toBeVisible(); - await expect(page.getByText("No imports yet.")).toBeVisible(); -}); diff --git a/e2e/tests/import-errors.spec.ts b/e2e/tests/import-errors.spec.ts deleted file mode 100644 index 9c6a2f1..0000000 --- a/e2e/tests/import-errors.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -// DoD scenario e2e_import_errors_actionable (SD-0002 §6.8): row-level errors are -// actionable — line, column, message — at the preview gate AND on the run report -// card (PUC-5), while the good rows still import. -import { expect, test } from "@playwright/test"; -import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers"; - -test("e2e_import_errors_actionable", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - await uploadFixture(page, "mixed-errors.csv"); - - // Preview tiles: two good products, one errored row. - await expect( - page.getByRole("heading", { name: "Import preview — mixed-errors.csv" }), - ).toBeVisible(); - await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible(); - await page.getByRole("button", { name: "1 errors" }).click(); - - // The error table names the line, the column, and the problem (actionable, PUC-5). - const previewRow = page.locator(".errortable tbody tr"); - await expect(previewRow).toHaveCount(1); - await expect(previewRow.locator("td").nth(0)).toHaveText("3"); - await expect(previewRow.locator("td").nth(1)).toHaveText("Variant Price"); - await expect(previewRow.locator("td").nth(2)).toContainText("is not a price"); - - // Good rows still import. - await page.getByRole("button", { name: "Import 2 products" }).click(); - - // Run detail: counts include the errored row, and the same error row persists. - await expect(page.getByRole("heading", { level: 1, name: "mixed-errors.csv" })).toBeVisible(); - await expect(page.getByText("2 added · 0 updated · 1 rows in error")).toBeVisible(); - const runRow = page.locator(".errortable tbody tr"); - await expect(runRow).toHaveCount(1); - await expect(runRow.locator("td").nth(0)).toHaveText("3"); - await expect(runRow.locator("td").nth(1)).toHaveText("Variant Price"); - await expect(runRow.locator("td").nth(2)).toContainText("is not a price"); - - // The catalog gained the two good products. - await gotoProducts(page); - await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible(); -}); diff --git a/e2e/tests/import-file-rejected.spec.ts b/e2e/tests/import-file-rejected.spec.ts deleted file mode 100644 index 3872296..0000000 --- a/e2e/tests/import-file-rejected.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -// DoD scenario e2e_import_file_rejected (SD-0002 §6.8): a file-level rejection -// (PUC-5a) renders in place on the upload screen, the picker stays live for a -// retry, and nothing is recorded — no products, no run. -import { expect, test } from "@playwright/test"; -import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers"; - -test("e2e_import_file_rejected", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - await uploadFixture(page, "missing-title.csv"); - - // The rejection renders in place, naming the missing column. - await expect(page.getByText("That file can't be imported")).toBeVisible(); - await expect(page.getByText("missing the required column 'Title'")).toBeVisible(); - - // The picker is live again for a retry. - await expect(page.locator('input[type="file"]')).toBeEnabled(); - - // No trace: still the empty catalog, and no run recorded. - await gotoProducts(page); - await expect( - page.getByText("No products yet. Bulk import is how product data gets in."), - ).toBeVisible(); - await expect(page.getByText("No imports yet.")).toBeVisible(); -}); diff --git a/e2e/tests/import-preview-confirm.spec.ts b/e2e/tests/import-preview-confirm.spec.ts deleted file mode 100644 index 7b8091b..0000000 --- a/e2e/tests/import-preview-confirm.spec.ts +++ /dev/null @@ -1,49 +0,0 @@ -// DoD scenario e2e_import_preview_confirm (SD-0002 §6.8): the happy path end to -// end — sign up, empty catalog, upload good.csv, preview gate (PUC-2/3), confirm, -// run report card, and the catalog + history reflecting the import. Subsumes the -// Task-15 harness smoke (sign-up journey + products empty state). -import { expect, test } from "@playwright/test"; -import { gotoProducts, signUpWithStorefront, STOREFRONT_NAME, uploadFixture } from "../helpers"; - -test("e2e_import_preview_confirm", async ({ page }) => { - await signUpWithStorefront(page); - - // Admin topbar: storefront identity + the signed-in account chip (ex-smoke). - await expect(page.locator(".storeid__name")).toHaveText(STOREFRONT_NAME); - await expect(page.getByRole("button", { name: "Sign out" })).toBeVisible(); - - await gotoProducts(page); - - // Empty state + the import affordances (SD-0002 §5.2, ex-smoke). - await expect( - page.getByText("No products yet. Bulk import is how product data gets in."), - ).toBeVisible(); - await expect(page.getByRole("link", { name: "Download sample CSV" })).toBeVisible(); - - await uploadFixture(page, "good.csv"); - - // Preview (§5.4): summary tiles + the file's name in the heading. - await expect(page.getByRole("heading", { name: "Import preview — good.csv" })).toBeVisible(); - await expect(page.getByRole("button", { name: "2 to add" })).toBeVisible(); - await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible(); - - // Drill in: open star-tee's diff row and see field-level detail (the S-variant SKU). - const starTee = page.locator(".difflist__item", { hasText: "star-tee" }); - await starTee.locator("summary").click(); - await expect(starTee.getByText("WG-TEE-S")).toBeVisible(); - - // Consent gate (PUC-3): confirm the import. - await page.getByRole("button", { name: "Import 2 products" }).click(); - - // Run detail (§5.5): report card with the file name, counts, and status. - await expect(page.getByRole("heading", { level: 1, name: "good.csv" })).toBeVisible(); - await expect(page.getByText("2 added · 0 updated · 0 rows in error")).toBeVisible(); - await expect(page.getByText("Complete", { exact: true })).toBeVisible(); - - // Back on Products: the catalog count and exactly one history row for this run. - await gotoProducts(page); - await expect(page.getByRole("heading", { level: 1, name: "Products · 2" })).toBeVisible(); - const rows = page.locator(".datatable tbody tr"); - await expect(rows).toHaveCount(1); - await expect(rows.first().getByRole("link", { name: "good.csv" })).toBeVisible(); -}); diff --git a/e2e/tests/import-shopify-dialect.spec.ts b/e2e/tests/import-shopify-dialect.spec.ts deleted file mode 100644 index 63a7b17..0000000 --- a/e2e/tests/import-shopify-dialect.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -// DoD scenario e2e_import_shopify_dialect (SD-0002 §6.8, SLICE-8): a Shopify -// product CSV imports directly (PUC-6). Upload an unmodified-shape Shopify export; -// the preview recognizes the dialect ("Shopify product CSV — mapped"), lists the -// columns with no canonical home as not-imported, and the import confirms and -// completes — the run report card carrying the same dialect label. -import { expect, test } from "@playwright/test"; -import { gotoProducts, signUpWithStorefront, uploadFixture } from "../helpers"; - -test("e2e_import_shopify_dialect", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - - await uploadFixture(page, "shopify-export.csv"); - - // Preview (§5.4): the file name, the recognized dialect, and the not-imported band. - await expect( - page.getByRole("heading", { name: "Import preview — shopify-export.csv" }), - ).toBeVisible(); - await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible(); - // The not-imported band lists Shopify columns with no canonical home. Assert a - // column that never appears in canonical diff detail, so the match is unambiguous. - await expect(page.getByText("Columns not imported")).toBeVisible(); - await expect(page.getByText("Variant Compare At Price")).toBeVisible(); - await expect(page.getByRole("button", { name: "1 to add" })).toBeVisible(); - await expect(page.getByRole("button", { name: "0 errors" })).toBeVisible(); - - // Consent gate (PUC-3): confirm the import. - await page.getByRole("button", { name: "Import 1 products" }).click(); - - // Run detail (§5.5): report card carries the same dialect label and completes. - await expect(page.getByRole("heading", { level: 1, name: "shopify-export.csv" })).toBeVisible(); - await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible(); - await expect(page.getByText("1 added · 0 updated · 0 rows in error")).toBeVisible(); - await expect(page.getByText("Complete", { exact: true })).toBeVisible(); -}); diff --git a/e2e/tests/roundtrip-noop.spec.ts b/e2e/tests/roundtrip-noop.spec.ts deleted file mode 100644 index bef0c82..0000000 --- a/e2e/tests/roundtrip-noop.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -// DoD scenario e2e_roundtrip_noop (SD-0002 §6.8, PUC-10): exporting then -// re-importing the unmodified file previews as all-unchanged with the import -// action disabled and the "Nothing to change" note. -import { expect, test } from "@playwright/test"; -import { exportCatalog, gotoProducts, importGoodCsv, signUpWithStorefront } from "../helpers"; - -test("e2e_roundtrip_noop", async ({ page }) => { - await signUpWithStorefront(page); - await gotoProducts(page); - await importGoodCsv(page); - await gotoProducts(page); - - // Export the catalog, then re-import the unmodified file. - const { path } = await exportCatalog(page, "All products"); - await page.getByRole("link", { name: "Import products" }).first().click(); - await expect(page.getByRole("heading", { name: "Import products" })).toBeVisible(); - await page.locator('input[type="file"]').setInputFiles(path); - - // Preview: everything unchanged, nothing to add/update (PUC-10). - await expect(page.getByRole("button", { name: "2 unchanged" })).toBeVisible(); - await expect(page.getByRole("button", { name: "0 to add" })).toBeVisible(); - await expect(page.getByRole("button", { name: "0 to update" })).toBeVisible(); - - // The import action is disabled, with the no-op note. - const importBtn = page.getByRole("button", { name: /^Import 0 products$/ }); - await expect(importBtn).toBeDisabled(); - await expect( - page.getByText("Nothing to change — your catalog already matches this file"), - ).toBeVisible(); -}); diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 567778d..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - ecomm - - -
- - - diff --git a/frontend/package-lock.json b/frontend/package-lock.json deleted file mode 100644 index 5a8e519..0000000 --- a/frontend/package-lock.json +++ /dev/null @@ -1,2174 +0,0 @@ -{ - "name": "wiggleverse-ecomm-frontend", - "version": "0.8.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "wiggleverse-ecomm-frontend", - "version": "0.8.0", - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "typescript": "^5.6.3", - "vite": "^5.4.11", - "vitest": "^2.1.8" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.371", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", - "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", - "dev": true, - "license": "ISC" - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/frontend/package.json b/frontend/package.json deleted file mode 100644 index c207e7a..0000000 --- a/frontend/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "wiggleverse-ecomm-frontend", - "private": true, - "version": "0.8.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "test": "vitest run" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "typescript": "^5.6.3", - "vite": "^5.4.11", - "vitest": "^2.1.8" - } -} diff --git a/frontend/public/brand/mark-mono-gold.svg b/frontend/public/brand/mark-mono-gold.svg deleted file mode 100644 index cc7fde2..0000000 --- a/frontend/public/brand/mark-mono-gold.svg +++ /dev/null @@ -1,8 +0,0 @@ - - Wiggleverse — mono gold - - - - - - \ No newline at end of file diff --git a/frontend/public/brand/mark-tile.svg b/frontend/public/brand/mark-tile.svg deleted file mode 100644 index 51d6c69..0000000 --- a/frontend/public/brand/mark-tile.svg +++ /dev/null @@ -1,9 +0,0 @@ - - Wiggleverse - - - - - - - \ No newline at end of file diff --git a/frontend/public/fonts/inter-v20-latin-500.woff2 b/frontend/public/fonts/inter-v20-latin-500.woff2 deleted file mode 100644 index 54f0a59..0000000 Binary files a/frontend/public/fonts/inter-v20-latin-500.woff2 and /dev/null differ diff --git a/frontend/public/fonts/inter-v20-latin-600.woff2 b/frontend/public/fonts/inter-v20-latin-600.woff2 deleted file mode 100644 index d189794..0000000 Binary files a/frontend/public/fonts/inter-v20-latin-600.woff2 and /dev/null differ diff --git a/frontend/public/fonts/inter-v20-latin-regular.woff2 b/frontend/public/fonts/inter-v20-latin-regular.woff2 deleted file mode 100644 index f15b025..0000000 Binary files a/frontend/public/fonts/inter-v20-latin-regular.woff2 and /dev/null differ diff --git a/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 b/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 deleted file mode 100644 index 0db251f..0000000 Binary files a/frontend/public/fonts/space-grotesk-v22-latin-500.woff2 and /dev/null differ diff --git a/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 b/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 deleted file mode 100644 index 44604a0..0000000 Binary files a/frontend/public/fonts/space-grotesk-v22-latin-700.woff2 and /dev/null differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx deleted file mode 100644 index 6ddc626..0000000 --- a/frontend/src/App.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// App shell — loads the session, applies the entry-routing rule (SD-0001 §6.5), and renders -// the matching screen. The rule is exhaustive: no state lands nowhere (BUC-4). The verify -// response carries the same shape as /me, so onAuthed feeds the session directly. -import { useEffect, useState } from "react"; -import { getMe, type StorefrontResult, type VerifyResult } from "./api"; -import { routeFor, type SessionState } from "./routing"; -import Admin from "./screens/Admin"; -import CreateStorefront from "./screens/CreateStorefront"; -import Landing from "./screens/Landing"; -import SignIn from "./screens/SignIn"; - -type Door = "signup" | "login"; -type Welcome = "new" | "back" | null; - -export default function App() { - const [session, setSession] = useState(null); - const [loading, setLoading] = useState(true); - const [door, setDoor] = useState(null); - const [welcome, setWelcome] = useState(null); - - async function reload() { - setLoading(true); - setSession(await getMe()); - setLoading(false); - } - - useEffect(() => { - void reload(); - }, []); - - if (loading) { - return ( -
-
-

Loading…

-
-
- ); - } - - const route = routeFor(session ?? { account: null, storefront: null }); - - if (route === "landing") { - if (door) { - return ( - setDoor(null)} - onAuthed={(result: VerifyResult) => { - setWelcome(result.created ? "new" : "back"); - setDoor(null); - setSession({ account: result.account, storefront: result.storefront }); - }} - /> - ); - } - return setDoor("signup")} onLogIn={() => setDoor("login")} />; - } - - const email = session!.account!.email; - - function signedOut() { - setWelcome(null); - setDoor(null); - setSession(null); - } - - if (route === "create-storefront") { - return ( - { - setWelcome(null); - setSession({ account: { email }, storefront: sf }); - }} - onAlreadyOwns={() => void reload()} - onSignedOut={signedOut} - /> - ); - } - - return ( - - ); -} diff --git a/frontend/src/adminRouting.test.ts b/frontend/src/adminRouting.test.ts deleted file mode 100644 index 76acf0d..0000000 --- a/frontend/src/adminRouting.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { adminViewFor, hashFor, type AdminView } from "./adminRouting"; - -const VIEWS: AdminView[] = [ - { view: "home" }, { view: "products" }, { view: "import-upload" }, - { view: "import-preview", draftId: 7 }, { view: "run-detail", runId: 12 }, -]; - -describe("adminRouting", () => { - it("round-trips every view", () => { - for (const v of VIEWS) expect(adminViewFor(hashFor(v))).toEqual(v); - }); - it("defaults junk to home/products", () => { - expect(adminViewFor("")).toEqual({ view: "home" }); - expect(adminViewFor("#/nonsense")).toEqual({ view: "home" }); - expect(adminViewFor("#/products/imports/drafts/abc")).toEqual({ view: "products" }); - }); -}); diff --git a/frontend/src/adminRouting.ts b/frontend/src/adminRouting.ts deleted file mode 100644 index adfb6ed..0000000 --- a/frontend/src/adminRouting.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Hash routing for admin sections (SD-0002 §5). The URL is the durable handle on a -// section (PUC-8: run detail can be left and returned to); the SD-0001 entry-routing -// rule (routing.ts) still decides whether the admin renders at all. -export type AdminView = - | { view: "home" } - | { view: "products" } - | { view: "import-upload" } - | { view: "import-preview"; draftId: number } - | { view: "run-detail"; runId: number }; - -export function adminViewFor(hash: string): AdminView { - const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean); - if (parts[0] !== "products") return { view: "home" }; - if (parts.length === 1) return { view: "products" }; - if (parts[1] === "import" && parts.length === 2) return { view: "import-upload" }; - if (parts[1] === "imports" && parts[2] === "drafts" && /^\d+$/.test(parts[3] ?? "")) - return { view: "import-preview", draftId: Number(parts[3]) }; - if (parts[1] === "imports" && parts[2] === "runs" && /^\d+$/.test(parts[3] ?? "")) - return { view: "run-detail", runId: Number(parts[3]) }; - return { view: "products" }; -} - -export function hashFor(v: AdminView): string { - switch (v.view) { - case "home": return "#/"; - case "products": return "#/products"; - case "import-upload": return "#/products/import"; - case "import-preview": return `#/products/imports/drafts/${v.draftId}`; - case "run-detail": return `#/products/imports/runs/${v.runId}`; - } -} diff --git a/frontend/src/api.ts b/frontend/src/api.ts deleted file mode 100644 index caf75cf..0000000 --- a/frontend/src/api.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Typed fetch wrappers for the /api/auth/* surface (SD-0001 §6.4). Same-origin via the Vite -// proxy; cookies carry the session. Errors return the §6.4 envelope; helpers normalize them. -import type { SessionState } from "./routing"; - -export interface ApiError { - code: string; - message: string; - retry_after_s?: number; - attempts_remaining?: number; -} - -export interface VerifyResult { - account: { email: string }; - storefront: { id: number; name: string } | null; - created: boolean; -} - -export async function errorOf(resp: Response): Promise { - try { - const body = await resp.json(); - if (body && body.error) return body.error as ApiError; - } catch { - /* fall through */ - } - return { code: "unexpected", message: "Something went wrong. Please try again." }; -} - -export async function getMe(): Promise { - const resp = await fetch("/api/auth/me", { credentials: "include" }); - if (resp.status === 401) return null; - if (!resp.ok) return null; - return (await resp.json()) as SessionState; -} - -export async function requestCode(email: string): Promise { - const resp = await fetch("/api/auth/request-code", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email }), - }); - return resp.ok ? null : await errorOf(resp); -} - -export async function verifyCode( - email: string, - code: string, -): Promise<{ ok: true; result: VerifyResult } | { ok: false; error: ApiError }> { - const resp = await fetch("/api/auth/verify", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify({ email, code }), - }); - if (resp.ok) return { ok: true, result: (await resp.json()) as VerifyResult }; - return { ok: false, error: await errorOf(resp) }; -} - -export async function logout(): Promise { - await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); -} - -export interface StorefrontResult { - id: number; - name: string; -} - -export async function createStorefront( - name: string, -): Promise<{ ok: true; storefront: StorefrontResult } | { ok: false; error: ApiError }> { - const resp = await fetch("/api/storefronts", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "include", - body: JSON.stringify(name.trim() ? { name: name.trim() } : {}), - }); - if (resp.ok) return { ok: true, storefront: (await resp.json()) as StorefrontResult }; - return { ok: false, error: await errorOf(resp) }; -} diff --git a/frontend/src/cooldown.test.ts b/frontend/src/cooldown.test.ts deleted file mode 100644 index 10405d1..0000000 --- a/frontend/src/cooldown.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { cooldownAppliesTo } from "./cooldown"; - -describe("resend cooldown keying (SD-0001 §5.2, INV-3; bug #20)", () => { - it("no cooldown running -> send not blocked", () => { - expect(cooldownAppliesTo("a@example.com", null, 0)).toBe(false); - }); - - it("cooldown running for the same address -> blocked", () => { - expect(cooldownAppliesTo("a@example.com", "a@example.com", 31)).toBe(true); - }); - - it("same address modulo case/whitespace -> still blocked", () => { - expect(cooldownAppliesTo(" A@Example.COM ", "a@example.com", 31)).toBe(true); - }); - - it("cooldown running but the input is a different address -> not blocked", () => { - expect(cooldownAppliesTo("right@example.com", "wrong@example.com", 31)).toBe(false); - }); - - it("cooldown expired -> not blocked even for the same address", () => { - expect(cooldownAppliesTo("a@example.com", "a@example.com", 0)).toBe(false); - }); -}); diff --git a/frontend/src/cooldown.ts b/frontend/src/cooldown.ts deleted file mode 100644 index 4e25b48..0000000 --- a/frontend/src/cooldown.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Resend-cooldown keying (SD-0001 §5.2, INV-3; bug #20). The server's cooldown is -// per-address, so the client countdown must be too: it blocks sending only while it -// is running AND the current input is the address it was set for. Normalization -// mirrors the backend's normalize_email (lowercase + strip, INV-2). - -function normalize(email: string): string { - return email.trim().toLowerCase(); -} - -export function cooldownAppliesTo( - input: string, - cooldownEmail: string | null, - secondsLeft: number, -): boolean { - if (secondsLeft <= 0 || cooldownEmail === null) return false; - return normalize(input) === normalize(cooldownEmail); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx deleted file mode 100644 index 0b18e9d..0000000 --- a/frontend/src/main.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import "./styles/index.css"; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/frontend/src/productsApi.dialect.test.ts b/frontend/src/productsApi.dialect.test.ts deleted file mode 100644 index dd53acc..0000000 --- a/frontend/src/productsApi.dialect.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { dialectLabel } from "./productsApi"; - -describe("dialectLabel", () => { - it("labels canonical", () => expect(dialectLabel("canonical")).toBe("Canonical format")); - it("labels shopify as mapped (PUC-6)", () => - expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped")); - it("passes through an unknown dialect verbatim", () => - expect(dialectLabel("weird")).toBe("weird")); -}); diff --git a/frontend/src/productsApi.export.test.ts b/frontend/src/productsApi.export.test.ts deleted file mode 100644 index 28c936e..0000000 --- a/frontend/src/productsApi.export.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { EXPORT_STATUSES, exportUrl } from "./productsApi"; - -describe("export url", () => { - it("lists the four status filters with 'all' first", () => { - expect(EXPORT_STATUSES.map((s) => s.value)).toEqual(["all", "active", "draft", "archived"]); - }); - it("builds the endpoint url with the status query", () => { - expect(exportUrl("all")).toBe("/api/products/export?status=all"); - expect(exportUrl("archived")).toBe("/api/products/export?status=archived"); - }); -}); diff --git a/frontend/src/productsApi.ts b/frontend/src/productsApi.ts deleted file mode 100644 index 94c2dbd..0000000 --- a/frontend/src/productsApi.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Typed fetch wrappers for the /api/products/* surface (SD-0002 §6.4). Same -// conventions as api.ts: same-origin, cookie session, §6.4 error envelope. -import { errorOf, type ApiError } from "./api"; - -export interface DiffSummary { adds: number; updates: number; unchanged: number; errors: number; } -export interface Draft { - id: number; file_name: string; dialect: string; - summary: DiffSummary; unknown_columns: string[]; expires_at: string; -} -export type RecordKind = "add" | "update" | "unchanged" | "error"; -export interface RowErrorDetail { line: number; column: string | null; message: string; } -export interface FieldChange { field: string; before: unknown; after: unknown; } -export interface VariantEntry { - options: (string | null)[]; kind?: string; - set?: Record; changes?: FieldChange[]; -} -export interface ImageEntry { src: string; kind?: string; position?: number; alt_text?: string | null; changes?: FieldChange[]; } -export interface DraftRecord { - handle: string; title: string; kind: RecordKind; variant_count: number; - detail: { - set?: Record; - option_names?: (string | null)[]; - changes?: FieldChange[]; - variants?: VariantEntry[]; - images?: ImageEntry[]; - errors?: RowErrorDetail[]; - }; -} -export interface ImageOutcome { - handle: string; url: string; outcome: string; reason: string | null; variant: string | null; -} -export interface ImageCounts { fetched: number; rejected: number; failed: number; } -export interface RunSummary { - id: number; file_name: string; dialect: string; created_at: string; - completed_at: string | null; status: string; by: string; - products_added: number; products_updated: number; rows_errored: number; - image_counts?: ImageCounts; -} -export interface RunDetail extends RunSummary { - errors: RowErrorDetail[]; - image_progress: { done: number; total: number }; - image_counts: ImageCounts; - image_outcomes: ImageOutcome[]; -} -export interface ProductsSummary { - product_count: number; image_problem_count: number; latest_run_id: number | null; -} - -export type Result = { ok: true; value: T } | { ok: false; error: ApiError; status: number }; - -// One label rule for CSV dialects, shared by Products history / preview / run detail. -export function dialectLabel(d: string): string { - if (d === "canonical") return "Canonical format"; - if (d === "shopify") return "Shopify product CSV — mapped"; - return d; -} - -export type ExportStatus = "all" | "active" | "draft" | "archived"; - -// PUC-9 status filter, 'all' first (the default). Labels drive the export menu. -export const EXPORT_STATUSES: { value: ExportStatus; label: string }[] = [ - { value: "all", label: "All products" }, - { value: "active", label: "Active" }, - { value: "draft", label: "Draft" }, - { value: "archived", label: "Archived" }, -]; - -// The export is a browser download (native save dialog + streaming), not a fetch -// wrapper — so the API module contributes a URL builder, not a request(). -export function exportUrl(status: ExportStatus): string { - return `/api/products/export?status=${status}`; -} - -async function request(path: string, init?: RequestInit): Promise> { - const resp = await fetch(path, { credentials: "include", ...init }); - if (!resp.ok) return { ok: false, error: await errorOf(resp), status: resp.status }; - if (resp.status === 204) return { ok: true, value: undefined as T }; - return { ok: true, value: (await resp.json()) as T }; -} - -export function getProductsSummary(): Promise> { - return request("/api/products/summary"); -} - -export function uploadImport(file: File): Promise> { - const body = new FormData(); - body.append("file", file); - // No content-type header: the browser sets the multipart boundary. - return request("/api/products/imports", { method: "POST", body }); -} - -export function getDraft(id: number): Promise> { - return request(`/api/products/imports/drafts/${id}`); -} - -export async function getDraftRecords( - id: number, kind?: RecordKind, limit = 100, offset = 0, -): Promise> { - const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - if (kind) params.set("kind", kind); - const resp = await request<{ records: DraftRecord[] }>( - `/api/products/imports/drafts/${id}/records?${params}`, - ); - return resp.ok ? { ok: true, value: resp.value.records } : resp; -} - -export function confirmDraft(id: number): Promise> { - return request(`/api/products/imports/drafts/${id}/confirm`, { method: "POST" }); -} - -export function cancelDraft(id: number): Promise> { - return request(`/api/products/imports/drafts/${id}`, { method: "DELETE" }); -} - -export async function listRuns(): Promise> { - const resp = await request<{ runs: RunSummary[] }>("/api/products/imports/runs"); - return resp.ok ? { ok: true, value: resp.value.runs } : resp; -} - -export function getRun(id: number): Promise> { - return request(`/api/products/imports/runs/${id}`); -} diff --git a/frontend/src/routing.test.ts b/frontend/src/routing.test.ts deleted file mode 100644 index 9e954fa..0000000 --- a/frontend/src/routing.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { routeFor } from "./routing"; - -describe("entry routing (SD-0001 §6.5)", () => { - it("no session -> landing", () => { - expect(routeFor({ account: null, storefront: null })).toBe("landing"); - }); - - it("session without storefront -> create-storefront", () => { - expect(routeFor({ account: { email: "m@example.com" }, storefront: null })).toBe( - "create-storefront", - ); - }); - - it("session with storefront -> admin", () => { - expect( - routeFor({ account: { email: "m@example.com" }, storefront: { id: 1, name: "Shop" } }), - ).toBe("admin"); - }); -}); diff --git a/frontend/src/routing.ts b/frontend/src/routing.ts deleted file mode 100644 index 16096dd..0000000 --- a/frontend/src/routing.ts +++ /dev/null @@ -1,16 +0,0 @@ -// The single client-side entry-routing rule (SD-0001 §6.5). Exhaustive: every state lands -// somewhere (BUC-4 — never stranded). Fed by one server answer (GET /api/auth/me, or the -// verify response, which share this shape). storefront is always null in SLICE-2; SLICE-3 -// makes "admin" reachable. -export interface SessionState { - account: { email: string } | null; - storefront: { id: number; name: string } | null; -} - -export type Screen = "landing" | "create-storefront" | "admin"; - -export function routeFor(s: SessionState): Screen { - if (!s.account) return "landing"; - if (!s.storefront) return "create-storefront"; - return "admin"; -} diff --git a/frontend/src/screens/Admin.tsx b/frontend/src/screens/Admin.tsx deleted file mode 100644 index e44dc45..0000000 --- a/frontend/src/screens/Admin.tsx +++ /dev/null @@ -1,88 +0,0 @@ -// Admin shell (SD-0001 §5.4) — the storefront's stable home; the home view is honestly -// empty this release (PUC-8; PUC-9 sign-out). Renders from /me alone: storefront name + -// signed-in email. No zeroed metric tiles, no locked-feature teasers (OHM: Agency & -// Anti-Manipulation). Visuals per the ui/designs export (hf-admin). SD-0002 §5 adds the -// admin nav strip + hash-routed products section (adminRouting.ts). -import { useEffect, useState } from "react"; -import { adminViewFor, type AdminView } from "../adminRouting"; -import { logout } from "../api"; -import { AccountChip, Banner, Eyebrow, Screen, TopBar } from "../ui/kit"; -import ImportPreview from "./products/ImportPreview"; -import ImportUpload from "./products/ImportUpload"; -import ProductsPage from "./products/ProductsPage"; -import RunDetail from "./products/RunDetail"; - -interface Props { - storefrontName: string; - email: string; - welcome: "new" | "back" | null; - onSignedOut: () => void; -} - -export default function Admin({ storefrontName, email, welcome, onSignedOut }: Props) { - const [view, setView] = useState(adminViewFor(window.location.hash)); - - useEffect(() => { - const onHashChange = () => setView(adminViewFor(window.location.hash)); - window.addEventListener("hashchange", onHashChange); - return () => window.removeEventListener("hashchange", onHashChange); - }, []); - - async function signOut() { - await logout(); - onSignedOut(); - } - - return ( - - - - - {storefrontName} - ecomm storefront - - - } - right={} - /> -
-
- {view.view === "home" && ( -
- {welcome && ( -
- - {welcome === "new" - ? "A new account was created for this email." - : "Signed in to your existing account."} - -
- )} - - Your storefront -

{storefrontName}

-

- There's nothing to manage yet — and that's a finished state, not a missing one. - Catalog, orders, and settings will appear here as ecomm grows. -

-
- )} - {view.view === "products" && } - {view.view === "import-upload" && } - {view.view === "import-preview" && } - {view.view === "run-detail" && } -
- - ); -} diff --git a/frontend/src/screens/CreateStorefront.tsx b/frontend/src/screens/CreateStorefront.tsx deleted file mode 100644 index 4b521ef..0000000 --- a/frontend/src/screens/CreateStorefront.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// Create storefront (SD-0001 §5.3) — a storefront-less Merchant establishes their one -// storefront (PUC-4/5; PUC-7 defense-in-depth on 409). Visuals per the ui/designs export -// (hf-storefront). The welcome banner carries PUC-3's honest copy from the verify step. -import { useState } from "react"; -import { createStorefront, logout, type StorefrontResult } from "../api"; -import { AccountChip, AuthCard, Banner, Eyebrow, Field, Footer, PrimaryButton, Screen, TopBar, Wordmark } from "../ui/kit"; - -interface Props { - email: string; - welcome: "new" | "back" | null; - onCreated: (storefront: StorefrontResult) => void; - onAlreadyOwns: () => void; - onSignedOut: () => void; -} - -export default function CreateStorefront({ email, welcome, onCreated, onAlreadyOwns, onSignedOut }: Props) { - const [name, setName] = useState(""); - const [busy, setBusy] = useState(false); - const [alreadyOwns, setAlreadyOwns] = useState(false); - const [error, setError] = useState(null); - - async function signOut() { - await logout(); - onSignedOut(); - } - - async function submit(e: React.FormEvent) { - e.preventDefault(); - setBusy(true); - setError(null); - const res = await createStorefront(name); - setBusy(false); - if (res.ok) { - onCreated(res.storefront); - return; - } - if (res.error.code === "already_owns_storefront") setAlreadyOwns(true); - else setError(res.error.message); - } - - return ( - - } right={} /> -
- -
-
- One storefront, fully yours -

Create your storefront

-

- This is the one thing to do right now. It costs nothing and commits you to nothing. -

-
- {welcome && ( - - {welcome === "new" - ? "A new account was created for this email." - : "Signed in to your existing account."} - - )} - {alreadyOwns && ( - - ecomm is one storefront per account today.{" "} - - - )} - {error && {error} Try again.} - setName(ev.target.value), - }} - /> -
- {busy ? "Creating…" : "Create storefront"} -

You can rename, configure, or close your storefront whenever you like.

-
- -
-
-