From df92e3f94c0e3e7947082058b62447f48c61d289 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 12 Jun 2026 09:30:01 -0700 Subject: [PATCH 1/2] =?UTF-8?q?chore(reset)!:=20strip=20storefront=20?= =?UTF-8?q?=E2=86=92=20network-service=20seed=20(v0.9.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot ecomm from a generic multi-tenant Shopify-alternative storefront to the commitment-commerce + cross-maker verified referral NETWORK direction (ecomm-content/rfcs/network_strategy.md; OHM identity/relationality super-drafts). Phase A of the reset — clear the decks: - Strip the storefront surfaces with no carry-forward: the storefronts domain, the products import/export/image HTTP spine (service/imagefetch/repo + endpoints), the SPA frontend, and the Playwright e2e suite. - Keep the reusable core: /healthz + /api/auth/* (accounts); the catalog-normalization library (codec/dialect/models/serialize/validate/diff/hosted — importable, no HTTP yet); platform/* infra. Migrations untouched (append-only). - Reduce check.sh to backend-only (import-linter + pytest); trim dev.sh and the unused GCS overlay env. Repoint app.json/README/CLAUDE.md; bump VERSION 0.8.0 -> 0.9.0. check.sh green: import-linter (main > domains > platform) KEPT, pytest passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 15 +- CLAUDE.md | 9 +- README.md | 8 +- VERSION | 2 +- app.json | 2 +- backend/app/domains/__init__.py | 8 +- backend/app/domains/products/__init__.py | 66 +- backend/app/domains/products/imagefetch.py | 146 -- backend/app/domains/products/repo.py | 616 ----- backend/app/domains/products/service.py | 265 -- backend/app/domains/storefronts/__init__.py | 19 - backend/app/domains/storefronts/errors.py | 10 - backend/app/domains/storefronts/models.py | 10 - backend/app/domains/storefronts/service.py | 56 - backend/app/main.py | 320 +-- backend/tests/test_auth_endpoints.py | 4 +- backend/tests/test_bootstrap.py | 19 +- backend/tests/test_products_endpoints.py | 146 -- backend/tests/test_products_export.py | 100 - backend/tests/test_products_hosted.py | 30 - backend/tests/test_products_image_serving.py | 78 - backend/tests/test_products_imagefetch.py | 159 -- backend/tests/test_products_invariants.py | 101 - backend/tests/test_products_repo_images.py | 93 - backend/tests/test_products_service.py | 242 -- backend/tests/test_static_spa.py | 24 - backend/tests/test_storefronts_create.py | 154 -- backend/tests/test_storefronts_invariants.py | 64 - deployment.toml | 5 +- e2e/.backend.log | 245 ++ e2e/.gitignore | 5 - .../storefronts/2/product-images/1/card.webp | Bin 0 -> 492 bytes .../2/product-images/1/detail.webp | Bin 0 -> 1220 bytes .../storefronts/2/product-images/1/original} | Bin .../storefronts/2/product-images/1/thumb.webp | Bin 0 -> 126 bytes e2e/fixtures/good.csv | 4 - e2e/fixtures/images/tiny.png | Bin 184 -> 0 bytes e2e/fixtures/missing-title.csv | 2 - e2e/fixtures/mixed-errors.csv | 4 - e2e/fixtures/shopify-export.csv | 2 - e2e/fixtures/with-images.csv | 4 - e2e/helpers.ts | 120 - e2e/image-host.mjs | 19 - e2e/package-lock.json | 76 - e2e/package.json | 6 - e2e/playwright.config.ts | 16 - e2e/serve.sh | 34 - e2e/test-results/.last-run.json | 4 + e2e/tests/export-download.spec.ts | 21 - e2e/tests/image-outcomes.spec.ts | 16 - e2e/tests/import-cancel.spec.ts | 23 - e2e/tests/import-errors.spec.ts | 41 - e2e/tests/import-file-rejected.spec.ts | 25 - e2e/tests/import-preview-confirm.spec.ts | 49 - e2e/tests/import-shopify-dialect.spec.ts | 35 - e2e/tests/roundtrip-noop.spec.ts | 30 - frontend/index.html | 15 - frontend/package-lock.json | 2174 ----------------- frontend/package.json | 24 - frontend/public/brand/mark-mono-gold.svg | 8 - frontend/public/brand/mark-tile.svg | 9 - .../public/fonts/inter-v20-latin-500.woff2 | Bin 24272 -> 0 bytes .../public/fonts/inter-v20-latin-600.woff2 | Bin 24452 -> 0 bytes .../fonts/inter-v20-latin-regular.woff2 | Bin 23664 -> 0 bytes .../fonts/space-grotesk-v22-latin-500.woff2 | Bin 13312 -> 0 bytes .../fonts/space-grotesk-v22-latin-700.woff2 | Bin 12840 -> 0 bytes frontend/src/App.tsx | 91 - frontend/src/adminRouting.test.ts | 18 - frontend/src/adminRouting.ts | 31 - frontend/src/api.ts | 79 - frontend/src/cooldown.test.ts | 24 - frontend/src/cooldown.ts | 17 - frontend/src/main.tsx | 10 - frontend/src/productsApi.dialect.test.ts | 10 - frontend/src/productsApi.export.test.ts | 12 - frontend/src/productsApi.ts | 122 - frontend/src/routing.test.ts | 20 - frontend/src/routing.ts | 16 - frontend/src/screens/Admin.tsx | 88 - frontend/src/screens/CreateStorefront.tsx | 92 - frontend/src/screens/Landing.tsx | 61 - frontend/src/screens/SignIn.tsx | 198 -- .../src/screens/products/ImportPreview.tsx | 393 --- .../src/screens/products/ImportUpload.tsx | 64 - .../src/screens/products/ProductsPage.tsx | 167 -- frontend/src/screens/products/RunDetail.tsx | 151 -- .../src/screens/products/exportMenu.test.ts | 19 - frontend/src/screens/products/exportMenu.ts | 11 - .../src/screens/products/runImages.test.ts | 25 - frontend/src/screens/products/runImages.ts | 32 - frontend/src/styles/app.css | 394 --- frontend/src/styles/fonts.css | 37 - frontend/src/styles/index.css | 8 - frontend/src/styles/products.css | 303 --- frontend/src/styles/tokens-colors.css | 60 - frontend/src/styles/tokens-spacing.css | 55 - frontend/src/styles/tokens-typography.css | 53 - frontend/src/ui/CodeInput.tsx | 46 - frontend/src/ui/kit.tsx | 150 -- frontend/tsconfig.json | 17 - frontend/vite.config.ts | 24 - scripts/check.sh | 9 +- scripts/dev.sh | 53 +- scripts/e2e.sh | 11 - 104 files changed, 353 insertions(+), 8400 deletions(-) delete mode 100644 backend/app/domains/products/imagefetch.py delete mode 100644 backend/app/domains/products/repo.py delete mode 100644 backend/app/domains/products/service.py delete mode 100644 backend/app/domains/storefronts/__init__.py delete mode 100644 backend/app/domains/storefronts/errors.py delete mode 100644 backend/app/domains/storefronts/models.py delete mode 100644 backend/app/domains/storefronts/service.py delete mode 100644 backend/tests/test_products_endpoints.py delete mode 100644 backend/tests/test_products_export.py delete mode 100644 backend/tests/test_products_hosted.py delete mode 100644 backend/tests/test_products_image_serving.py delete mode 100644 backend/tests/test_products_imagefetch.py delete mode 100644 backend/tests/test_products_invariants.py delete mode 100644 backend/tests/test_products_repo_images.py delete mode 100644 backend/tests/test_products_service.py delete mode 100644 backend/tests/test_static_spa.py delete mode 100644 backend/tests/test_storefronts_create.py delete mode 100644 backend/tests/test_storefronts_invariants.py create mode 100644 e2e/.backend.log delete mode 100644 e2e/.gitignore create mode 100644 e2e/.objectstore/storefronts/2/product-images/1/card.webp create mode 100644 e2e/.objectstore/storefronts/2/product-images/1/detail.webp rename e2e/{fixtures/images/good.png => .objectstore/storefronts/2/product-images/1/original} (100%) create mode 100644 e2e/.objectstore/storefronts/2/product-images/1/thumb.webp delete mode 100644 e2e/fixtures/good.csv delete mode 100644 e2e/fixtures/images/tiny.png delete mode 100644 e2e/fixtures/missing-title.csv delete mode 100644 e2e/fixtures/mixed-errors.csv delete mode 100644 e2e/fixtures/shopify-export.csv delete mode 100644 e2e/fixtures/with-images.csv delete mode 100644 e2e/helpers.ts delete mode 100644 e2e/image-host.mjs delete mode 100644 e2e/package-lock.json delete mode 100644 e2e/package.json delete mode 100644 e2e/playwright.config.ts delete mode 100755 e2e/serve.sh create mode 100644 e2e/test-results/.last-run.json delete mode 100644 e2e/tests/export-download.spec.ts delete mode 100644 e2e/tests/image-outcomes.spec.ts delete mode 100644 e2e/tests/import-cancel.spec.ts delete mode 100644 e2e/tests/import-errors.spec.ts delete mode 100644 e2e/tests/import-file-rejected.spec.ts delete mode 100644 e2e/tests/import-preview-confirm.spec.ts delete mode 100644 e2e/tests/import-shopify-dialect.spec.ts delete mode 100644 e2e/tests/roundtrip-noop.spec.ts delete mode 100644 frontend/index.html delete mode 100644 frontend/package-lock.json delete mode 100644 frontend/package.json delete mode 100644 frontend/public/brand/mark-mono-gold.svg delete mode 100644 frontend/public/brand/mark-tile.svg delete mode 100644 frontend/public/fonts/inter-v20-latin-500.woff2 delete mode 100644 frontend/public/fonts/inter-v20-latin-600.woff2 delete mode 100644 frontend/public/fonts/inter-v20-latin-regular.woff2 delete mode 100644 frontend/public/fonts/space-grotesk-v22-latin-500.woff2 delete mode 100644 frontend/public/fonts/space-grotesk-v22-latin-700.woff2 delete mode 100644 frontend/src/App.tsx delete mode 100644 frontend/src/adminRouting.test.ts delete mode 100644 frontend/src/adminRouting.ts delete mode 100644 frontend/src/api.ts delete mode 100644 frontend/src/cooldown.test.ts delete mode 100644 frontend/src/cooldown.ts delete mode 100644 frontend/src/main.tsx delete mode 100644 frontend/src/productsApi.dialect.test.ts delete mode 100644 frontend/src/productsApi.export.test.ts delete mode 100644 frontend/src/productsApi.ts delete mode 100644 frontend/src/routing.test.ts delete mode 100644 frontend/src/routing.ts delete mode 100644 frontend/src/screens/Admin.tsx delete mode 100644 frontend/src/screens/CreateStorefront.tsx delete mode 100644 frontend/src/screens/Landing.tsx delete mode 100644 frontend/src/screens/SignIn.tsx delete mode 100644 frontend/src/screens/products/ImportPreview.tsx delete mode 100644 frontend/src/screens/products/ImportUpload.tsx delete mode 100644 frontend/src/screens/products/ProductsPage.tsx delete mode 100644 frontend/src/screens/products/RunDetail.tsx delete mode 100644 frontend/src/screens/products/exportMenu.test.ts delete mode 100644 frontend/src/screens/products/exportMenu.ts delete mode 100644 frontend/src/screens/products/runImages.test.ts delete mode 100644 frontend/src/screens/products/runImages.ts delete mode 100644 frontend/src/styles/app.css delete mode 100644 frontend/src/styles/fonts.css delete mode 100644 frontend/src/styles/index.css delete mode 100644 frontend/src/styles/products.css delete mode 100644 frontend/src/styles/tokens-colors.css delete mode 100644 frontend/src/styles/tokens-spacing.css delete mode 100644 frontend/src/styles/tokens-typography.css delete mode 100644 frontend/src/ui/CodeInput.tsx delete mode 100644 frontend/src/ui/kit.tsx delete mode 100644 frontend/tsconfig.json delete mode 100644 frontend/vite.config.ts delete mode 100755 scripts/e2e.sh 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/.backend.log b/e2e/.backend.log new file mode 100644 index 0000000..bf634e0 --- /dev/null +++ b/e2e/.backend.log @@ -0,0 +1,245 @@ +INFO: Started server process [39018] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://127.0.0.1:8765 (Press CTRL+C to quit) +INFO: 127.0.0.1:58472 - "GET /healthz HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58474 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256953768-0@example.com | Your ecomm code: 580511 +Your ecomm one-time code is 580511. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58476 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58476 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58474 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 2, "dialect": "canonical", "duration_ms": 4, "errors": 0, "event": "import_draft_created", "row_count": 3, "storefront_id": 1, "unchanged": 0, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58476 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58476 - "GET /api/products/imports/drafts/1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /api/products/imports/drafts/1/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"added": 2, "duration_ms": 7, "errored": 0, "event": "import_run_completed", "run_id": 1, "storefront_id": 1, "updated": 0} +INFO: 127.0.0.1:58476 - "POST /api/products/imports/drafts/1/confirm HTTP/1.1" 201 Created +INFO:ecomm.telemetry: {"duration_ms": 2, "event": "image_phase_completed", "failed": 0, "fetched": 0, "rejected": 0, "run_id": 1} +INFO: 127.0.0.1:58476 - "GET /api/products/imports/runs/1 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58476 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58474 - "GET /api/products/export?status=all HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"duration_ms": 4, "event": "catalog_exported", "product_count": 2, "status_filter": "all", "storefront_id": 1} +INFO: 127.0.0.1:58474 - "GET /api/products/export?status=active HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"duration_ms": 8, "event": "catalog_exported", "product_count": 2, "status_filter": "active", "storefront_id": 1} +INFO: 127.0.0.1:58479 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58479 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58479 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58479 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58479 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58479 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256954851-1@example.com | Your ecomm code: 391458 +Your ecomm one-time code is 391458. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58481 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58481 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58479 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 3, "dialect": "canonical", "duration_ms": 10, "errors": 0, "event": "import_draft_created", "row_count": 3, "storefront_id": 2, "unchanged": 0, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58481 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58481 - "GET /api/products/imports/drafts/2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /api/products/imports/drafts/2/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"added": 3, "duration_ms": 13, "errored": 0, "event": "import_run_completed", "run_id": 2, "storefront_id": 2, "updated": 0} +INFO: 127.0.0.1:58481 - "POST /api/products/imports/drafts/2/confirm HTTP/1.1" 201 Created +INFO: 127.0.0.1:58481 - "GET /api/products/imports/runs/2 HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"duration_ms": 154, "event": "image_phase_completed", "failed": 1, "fetched": 1, "rejected": 1, "run_id": 2} +INFO: 127.0.0.1:58481 - "GET /api/products/imports/runs/2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58479 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58481 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58489 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58487 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58489 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58489 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256957956-2@example.com | Your ecomm code: 342006 +Your ecomm one-time code is 342006. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58487 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58487 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58489 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 2, "dialect": "canonical", "duration_ms": 4, "errors": 0, "event": "import_draft_created", "row_count": 3, "storefront_id": 3, "unchanged": 0, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58487 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58487 - "GET /api/products/imports/drafts/3 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /api/products/imports/drafts/3/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "DELETE /api/products/imports/drafts/3 HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58489 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58487 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58494 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58492 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58494 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58494 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256958663-3@example.com | Your ecomm code: 411253 +Your ecomm one-time code is 411253. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58492 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58492 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58494 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 2, "dialect": "canonical", "duration_ms": 6, "errors": 1, "event": "import_draft_created", "row_count": 3, "storefront_id": 4, "unchanged": 0, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58492 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58492 - "GET /api/products/imports/drafts/4 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /api/products/imports/drafts/4/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /api/products/imports/drafts/4/records?limit=100&offset=0&kind=error HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"added": 2, "duration_ms": 11, "errored": 1, "event": "import_run_completed", "run_id": 3, "storefront_id": 4, "updated": 0} +INFO: 127.0.0.1:58492 - "POST /api/products/imports/drafts/4/confirm HTTP/1.1" 201 Created +INFO:ecomm.telemetry: {"duration_ms": 2, "event": "image_phase_completed", "failed": 0, "fetched": 0, "rejected": 0, "run_id": 3} +INFO: 127.0.0.1:58492 - "GET /api/products/imports/runs/3 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58494 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58492 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58499 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58497 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58499 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58499 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256959226-4@example.com | Your ecomm code: 672118 +Your ecomm one-time code is 672118. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58497 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58497 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58499 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "POST /api/products/imports HTTP/1.1" 400 Bad Request +INFO: 127.0.0.1:58499 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58497 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58503 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58501 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58503 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58503 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256959685-5@example.com | Your ecomm code: 541296 +Your ecomm one-time code is 541296. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58501 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58501 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58503 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 2, "dialect": "canonical", "duration_ms": 5, "errors": 0, "event": "import_draft_created", "row_count": 3, "storefront_id": 6, "unchanged": 0, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58501 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58501 - "GET /api/products/imports/drafts/5 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /api/products/imports/drafts/5/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"added": 2, "duration_ms": 5, "errored": 0, "event": "import_run_completed", "run_id": 4, "storefront_id": 6, "updated": 0} +INFO: 127.0.0.1:58501 - "POST /api/products/imports/drafts/5/confirm HTTP/1.1" 201 Created +INFO:ecomm.telemetry: {"duration_ms": 2, "event": "image_phase_completed", "failed": 0, "fetched": 0, "rejected": 0, "run_id": 4} +INFO: 127.0.0.1:58501 - "GET /api/products/imports/runs/4 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58501 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58503 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58507 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58505 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58507 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58507 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256960220-6@example.com | Your ecomm code: 765123 +Your ecomm one-time code is 765123. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58505 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58505 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58507 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 1, "dialect": "shopify", "duration_ms": 4, "errors": 0, "event": "import_draft_created", "row_count": 1, "storefront_id": 7, "unchanged": 0, "unknown_columns_count": 4, "updates": 0} +INFO: 127.0.0.1:58505 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58505 - "GET /api/products/imports/drafts/6 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58505 - "GET /api/products/imports/drafts/6/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"added": 1, "duration_ms": 7, "errored": 0, "event": "import_run_completed", "run_id": 5, "storefront_id": 7, "updated": 0} +INFO: 127.0.0.1:58505 - "POST /api/products/imports/drafts/6/confirm HTTP/1.1" 201 Created +INFO:ecomm.telemetry: {"duration_ms": 1, "event": "image_phase_completed", "failed": 0, "fetched": 0, "rejected": 0, "run_id": 5} +INFO: 127.0.0.1:58505 - "GET /api/products/imports/runs/5 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /assets/index-BXAA3-2r.js HTTP/1.1" 200 OK +INFO: 127.0.0.1:58512 - "GET /assets/index-DKKZujRA.css HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /api/auth/me HTTP/1.1" 401 Unauthorized +INFO: 127.0.0.1:58510 - "GET /fonts/inter-v20-latin-regular.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58512 - "GET /brand/mark-mono-gold.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /brand/mark-tile.svg HTTP/1.1" 200 OK +INFO: 127.0.0.1:58512 - "GET /fonts/space-grotesk-v22-latin-500.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /fonts/space-grotesk-v22-latin-700.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /fonts/inter-v20-latin-500.woff2 HTTP/1.1" 200 OK +INFO:ecomm.mailer: LogMailer -> merchant1781256960905-7@example.com | Your ecomm code: 873468 +Your ecomm one-time code is 873468. +It is valid for 10 minutes. +If you didn't request this, ignore this message. +INFO: 127.0.0.1:58510 - "POST /api/auth/request-code HTTP/1.1" 204 No Content +INFO: 127.0.0.1:58510 - "POST /api/auth/verify HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /fonts/inter-v20-latin-600.woff2 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "POST /api/storefronts HTTP/1.1" 201 Created +INFO: 127.0.0.1:58512 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"adds": 2, "dialect": "canonical", "duration_ms": 2, "errors": 0, "event": "import_draft_created", "row_count": 3, "storefront_id": 8, "unchanged": 0, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58510 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58510 - "GET /api/products/imports/drafts/7 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /api/products/imports/drafts/7/records?limit=100&offset=0 HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"added": 2, "duration_ms": 4, "errored": 0, "event": "import_run_completed", "run_id": 6, "storefront_id": 8, "updated": 0} +INFO: 127.0.0.1:58510 - "POST /api/products/imports/drafts/7/confirm HTTP/1.1" 201 Created +INFO:ecomm.telemetry: {"duration_ms": 1, "event": "image_phase_completed", "failed": 0, "fetched": 0, "rejected": 0, "run_id": 6} +INFO: 127.0.0.1:58510 - "GET /api/products/imports/runs/6 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58512 - "GET /api/products/imports/runs HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /api/products/summary HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /api/products/export?status=all HTTP/1.1" 200 OK +INFO:ecomm.telemetry: {"duration_ms": 4, "event": "catalog_exported", "product_count": 2, "status_filter": "all", "storefront_id": 8} +INFO:ecomm.telemetry: {"adds": 0, "dialect": "canonical", "duration_ms": 6, "errors": 0, "event": "import_draft_created", "row_count": 3, "storefront_id": 8, "unchanged": 2, "unknown_columns_count": 0, "updates": 0} +INFO: 127.0.0.1:58510 - "POST /api/products/imports HTTP/1.1" 201 Created +INFO: 127.0.0.1:58510 - "GET /api/products/imports/drafts/8 HTTP/1.1" 200 OK +INFO: 127.0.0.1:58510 - "GET /api/products/imports/drafts/8/records?limit=100&offset=0 HTTP/1.1" 200 OK 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/.objectstore/storefronts/2/product-images/1/card.webp b/e2e/.objectstore/storefronts/2/product-images/1/card.webp new file mode 100644 index 0000000000000000000000000000000000000000..0a9622843c90ce3cf64574949c7382af4d0e5605 GIT binary patch literal 492 zcmWIYbaQ*c$iNWp>J$(bV4-jW$QCeRn9Hd3fbjvNU82D>k0r`ViV7SI4LqEgc@N7I z62@`UfV)fL}w^K-tRGpBu2n#RHMwSnQ^ d|G9Dh|34Qx9=vJ$(bV4<)9$TsL=n9Hc8z^uS*muN7}V~Mhoq5=m)0}p3r-ox@l zxjXC+7#M_aud-KhWn-(s58+`Bjb|34$NU*T>M I&>A2B08V%>kN^Mx literal 0 HcmV?d00001 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/tiny.png b/e2e/fixtures/images/tiny.png deleted file mode 100644 index 293a859482e7cb3be072b10dcbe4ede8be9cbd24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 184 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJMdQTU}kcv51&p8S*DDWKE5M=s! uJyT@IgN@lDPwq&bQt^94J9LNdPZdW!;|z}Sjmv;eVeoYIb6Mw<&;$TS2Sk7X 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/test-results/.last-run.json b/e2e/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/e2e/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file 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 54f0a595dd3c76344a980109e7517ed42416de93..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24272 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 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 f15b025d666269fb07daf0263e54231481e4289c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23664 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 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 0db251fc0bcec46bafd3fe49ebb5acf7d93284ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13312 zcmV+bH2=$YPew8T0RR9105kvq5C8xG0Ddq405hBb0RR9100000000000000000000 z0000Qf+!ovG8~Cc24Fu^R6$fK0EAi*2nvCL2X!wGFC^1_CWVTi;E#3Dr!O$8oYiA}GgY52fwL0{B9ri2oQ za%I?J7#!<#y-g< z`ttYudd|Lo_q!sZ9#do#5uOp^kxE3SIYn%`KhMwY&wcN)eq$SB4SQ@OY8x3tQfJRh zqCzo2vG8M5qZo+^_(d#?29>aq;c;94t}Kk59ww4y00uDzjExu-f}(&5ib|AvQSl=$ ze&yY7e`fRaGGKdmpFv@yNTVb+k|7$=5p0GMY{ks+56^zBi&--L%$bB*BWW!VA|W(3 z1@;BN(EeU2^tCmmwBA09)AOK%eZRT-vB|4j0&sh%PR+E3pe-w4M47H91aV>23gCaf z{U)I+pQb#9KQj)zrk6k zD|apXVaEypR=^d|BS_Z$`5w6pklo5{fT5_8q6&hW*nn&OkB^P*O@&xjrk3e823rq; zGQ?e-KzX91cQ%O5$otd>;cU-v7qf#U09u%%1KtpacjP-f?+u1avKAl@Z@kMC0pL4B zzQxPh%=*Z>P@#fY3Y953rOF}~ek2!Ux#0hwE9IYiLi@#F0LLxM{*s+WYr_@kscNx= z!3tn`_}cqUpV>6oeQrusiBJKodC)IAe>|nhou{C$k1;V0VOtTdG|3<&%AkQK_PM+_ zAY8#ZvL(nOK_q{z_}@_1M>KJyu3RofijmTP^ox`tj=f7fs(-d+ zFj52tghc?cB!S4tAPOpohAqT_3q;2q;>i=@%?FZV&N9`iAx)ZLTC~93brMH(3#f`GXYjuv-4((Y(DBr?K>7yC?ZZIBO9eovOz?+d zkXke@>`-KcxRA$ff@L1od`MFpjJ8@FdfL)M_iK}+1#AXd;DC0X!WnQ990LcyUa$*n z2V1~;Sh@JcaRVVDj}1_xVNS5Bz~NL$35tW!FhGC5FRwctd6D+Cr6t#M36S^Fk`o}W zXHT|eLtbQ6mS<7sr6NW7BHNM!^dJdp(B4iOB<5mCl7T96&00@)ach(MOc7-m?A zfPa4YZv(GJWET(>>4KD{3(B=Nx4WxBGk_D;n^nd5m?}$L z9sC4rtGRLjPG%S=PaCN`mT_IMKV>3ParW-5{UpZT)4t-QFr3}mbq`5`qylABZt((M zIaOeXU4np)0Q4CG1~Vu}5qrc3rxm6TCI2DVkVG9lJSb4#?9U_mcrqZ{(RoiRw=) zfrTz?ONjM@TFoQC#=KC%o7UDqey0bO|CTGtVcc6-FjV=rhg&SpU2}>+f3JkPFP<}l z&0=9PRn@_zR$VgIJ5yfZ^8m#)o#<;g z*eXKBR(fdg@;j!M!-@QV%;w{xUhkv2 z)!c1+rD9Y;BfNKKfIKOqQ#~<$`F+dR{x4|Rmo_Zfx4y_XeP={Ye^gal22{9p+2&$a zkXU>C+I)#x|EC3JvaMeKi*vgYUjn00IAZlP$l!I+3^sm__m@0?vgNMSef{IKt?U+H zZjEI8^=h$wON)ApUhep^k0cmu4-pF*id8hb859us#yHjqNc=F;kw7pqv63d zfah%@)7v0gYM;{cHY=w;R5H+bZsMtJg0h&|~a_poKtikr&Gjz&t|_JMSjWg(oB;w*s~ZgpPs z2X{GOQ>65)hFD*5^e58TrJ+Nym#W?`j@iV4>R46&Y{2+J3i_pcv@}qdH6}g+-83}^ zxv_g5w&+ik(a*TZ)e|)E<7t)3n%#MH=ZmJQ zw_iWKIr(wf%)hbXN##a?6KbnC*BJYF+9WBjU-bxFIoDfB@#Q?rX&%*o@2nCYR8h&u zH$qtOzL9U_9-P`O%I!N=7~^qXij_iozrnn3Zie(oXBmaFy*!Jf~TySF|eErcH&19-8I3 z=Vp7YQ>AadS>T5s7V0xR6%vt99YF*ZMH!{VFvF}wpfO{Hj)n$9pA<_Bsfa*~P>e_p zu|g6FQk}^Zlj}kukChVEno%~HC@P3r7LADQXC^!L*#EYMd39v?g%NR;K`Ww@K`+To z7I$$Ta(OD_r7Lf7KFaw@@zajKP6Biks7A03LbMkui$RWlGDZB%WaQFl7@sy?Xe)9z zr$Q=!VYyuM8Wh+BQ*JgwrAj<=RWq~5GRSf(u+>_L??3-xsIwlj(Ka}zFkvA=fB;3y z(uG#O$@X1_AF}MinuoqZ!jS|bUU<5c6q84EwfvFo-Q-Y0yhE$5H9rNPN`Dg?p z7P=FPSjs1oMXZcOj8qRYt;qGHkjv^KM&oFMOllq3hJi!T7aTR^9*Q*rSip{geK;uN z#XG{N8Ap$Yh2M&Wo#hGt$}>=*R!28$g%n&V)n;kkGU*DeKxpgaOS3|O zjg~130R>obt7?S<^uGDANu+C*sr7p)0%fzH>FABO=sINT4E-#05JFc|WZUecLLLfB zDDYwM@YAtg(ewITPxeSB=G5z_)!n^Ix}X!ZU)Ostrq(h}P6wu44rqPTmZ^41n z$v@ycY?-r?2y2uknn=_SXo2Dd`<986`mSEZa`+qH;nX|ZrWfnBYP$keg}xOeYoZFz zdOdA9l&ViGO3^YbC<6suN}FFKrv8fdM+_;n0>KO&6Um%_1>sXbQd&_gSWu#us>nW))_ct_V+YHO^u&UzbccT|(x{&z>K7f*+OZm0LUee)xL8e$_Z z5=0zDD9F+8*Y*WtGh;WMz2o?B^u7$xWUf3>SVh%)VR=4c@751@{r|0R%n#P*9#&su z>O9e^d<;d|VN=iGroG2Bo(nXQ&3NsNw>otGKAI>ftB5dzgl>C{8k5R$( zEG*N=IKiAkQj|U{Zi=LUv~99JDe~SrfeNq@s;2IMhOpO$7LJ2u&D zi>HmRc6I!mZ3Ytjg6`W36@8$Hvkon{BbxHcgsaYoA`@jkno>&U0s% z)Z&6Y)p}G0n?y^X*_hNF&8>WsYWCV2Z*}NQyq6p>Akq5ZCu9H#%Xz~|2kqkOj$8O5 z>ztzis6b1&UbBp=Gz=cC*C|0Re8S<}9tZ_B}U_<-dpDC%yF#iiWKLK-}@v&1N z`I$N`31WjX-1&cJCue~3Yj+(if3P0~gs@l?PN5Z6p|hN{7k-@6k+u3wbX0Yp7Y=zQrpP*D)!X@+ss31hDb z#?EUPwGKEnM{m0&dvAtDLFvX-;Qt4(SYyQ|1hR@mj}I|N9C#UIog@1tLTLmt7!)E{ z0Dry^mX|*02|@(o3~ftd6`K(;PNnEt@$~Q%And6`nIJvw2T&6c_TU}3p>plJ^AGPD&0r&v9)hhh{ z|Lrh)EbJ`+Jq^f@)c|Y(hjI`CE8icm5qAIeaajle1p(5~7jVIm0V~0}5K)m||K~ug zB1F>h6DnlPq7ClXsM%9*_2|o<8D2|ur5;v$LpXsmarG8VY5}#9l2O)_in60TD1R!P z+CyNZI{Fzc8w*D3sf-mAJ%0w{Oi^fqbFOOfOo!fFp<#m=w}nql%|FXx*c$d;<@NCN zb+0He584B50BUW6np>bl!2kL~i3jla&QI;Xd>g>;%N5{veD+rR+MmrepRgMO&H~&9 zcmVK(IRGC3d=LEY2LQh+Uct`*yj|D-hs_u^H#z8-nFttM!rev~7Vdy#L+N=#Sl|de&__eRkXl^~TFs zxD!`xsXF5Zi|?q3)BcaGjW*eAi>^q9p_9<`1sLHNS0O5ciAi9L?f1aStu z2SsABvr2Ji+gVVgb>qiOv#Ff`WEX{HU$&>J%2O8=(ICXMWMsarET<^>5|h)-OF@$$iYTXfn>@!6wdHqIJ1$O6G=jx&9gD`lH z3yaMRkLbd0B93EkTD6u(lw&C%0w(9n=lCu{3e6SXg2Zz|=C<635;^X7h1Ri~*AgmzXnn%^eDn zrkEN_T}UwO-K^B*6$SniS^i4bUoP*fa<3cMDA^U^xCjGVz-Fe-tn!>qT@YOgAEx_i*B`5Ntmdf5{jiVMrg zQwrf<goMCNgeotu`eMnL2 zld_b+m-j^ANsd|57-_QpxHf1Nz3?SZC`dGehC_zLa&z|3Q0z{r=v;R$BP6~~K8POU z&78>>X3Mg&LfAasJi#vTA{DhyZ+bnNwWFX#kx(?dA;%8hx-vD9DgL7Tc z-G`(0pksx(~qbhG5#?IHRvV6-=E&u$`D)3v zH|Lc94j%vacZDvtMwFRSfMEUD$M2`K8H0II!hOiQ1({E3jD3;X?heFP{EdN?a|x`w zz|yPfZg@~FU~4Go&ALZTHzF~TB1B^kvReP}?KPF+%XenD>F56@XzEwSuK2{l?npz1 zHWkL^J2r^`Bs2mgr9JTQ6JXUQ>RpRRAK>SmW?<3zG)@#BSY>@zUa+x0=iOr3(Bs{$ zVQ_@^B)?`?b}Z#}MPq>pF~m?!luC65OB?rGU&ut4daA`?Z@VTeAF`)WuIm#LmkEp9 z^G|d(w@Xb%3XO&X%pSN#i!|;DH+;?9M$>Vb&YD9C+bpQA>+icQ4j#Gq+ov1J7@uwX0Wx zoslAz=9_zm)pn1@`$GbOl1K5Jja?M$4|-vQM?r=xId+^dhH1#LrkS?1nP*p&XYW$b z%a#xX!N&ccts4T)Q+!%lL6*Ghkp(IkSpKt2l~Lzr1Jq zPG~z;rM$9gDaMf$30vccs}2SM_$&xwUnP|#NxkM=()<2o#cYr3{t*6iy|(M!Y4fRW z)1#BgjQf>AdEq&p&wnPizp6$RsxfmijFmY+lU9488yDP*dx#V0{fnA^uAi!{z#IaQ!8d#ZmF9MT`#Ya z+j`8#a)SQoeIVY%Vt*B_b(TeMMryv57jm~td$ul3fQ^r#(YhM!Wr%eec;?ehlo z2I6W<9Xk(u$l+Xp8zbDoTl;62#U2-P z9Z@8ys!8N>g9+>`z8I|I@k@L(Y{z3`^zm-a>l4Z(aSTyn#34w1Q9>0?qUCQLGhS(0 zlbxbhOgks+M~fO4Aq62i1$i^;*spnWw}cE8CjCp}R#|d>a~~1n&UY+HjxS}JPOezn zG25;hZoeKDz-P5>Szb=0-d~F1pP7y$4{&|C9`$^{Zgnh~8_5JR5 z`L0+avWe|05y3%tVASK%8m9P92hH0JKX+#<36N$=k=W}GkbKvxD_zsM(l>OLw9f~} zV^K=fF&Pf(hh&{0nUkmKN3_ee#5FkN(+8#XZPJGNOSc|5L8b5c_wGN~n!)Dt%KSC9 zR;dKBvAVmg%x9c%?(LohcaJQA+JI%_sWXqAN|l^xl$X0o~-tKNBa%wIf2g zy|1P_h%pIG=xx07iaH%c2wR=1U)Z&m`GeBB@7Vr>TMr)Fw~vV0!+~&k&v0NU zYzHw7cg1W^_Dt=0c3AA@?r)BvPYI_Ar67Q`77@exF#9d;+?8LIsuJ0t2A-l?LCobe z2e7)O3B1ra4sGEfZO=~3h`HjKn2~a&WAEA$V36@^3n}%CdO)pL4k&bkyB1^en0?o5 zCO7d;SkOvlwJM0k4wD>sf?jXP6~Nfj|7Aj#Jv_Y>pMlOx-=2grcLFb*P8L$rv@ECy z29ujt&N~g6&7033QTDKD%ri9Rz22MgxWb?q1Iunk#(M{bb#*%BNW4dTZC&bRg9eqn zM!~5ucp1ar?6vRR85);GkY3Y!L22U1;TbFN1hwXjoc39f4s zBbdC4{zd(@XhVHMs&Z(x@JL<`ddJ?2wZ~o$fkC9Fq(U;TXJ((bxEuuGa#)_9dB&y9 zlvUvVg;QzQlzw*bPL1Pn zncBBCz6SYqy^9Ft@=Wq-xxE?Xh;#=1)_C)+YG1q;h)GSX3eGqi9GWaYux_N~*lDOS zu{41}U4%^N;UASNX-&g$j;OV1a&xd5pN3UwD{t*G#7)4;(?6P=-yY}_yyVUA$6=+? z1cR-$djflABtFT;K%|ozIuJN;0(g8jx5Kju6>K6DR0tn;ON>$?B<`eM0-Y=(Ej@5j zshIiuq8wbkzh#)bqLI%0UN%h%u26GHe2I`WTiDB*Gbx&NIUgdRLQ z|Br6?JMa2U-mP9MOTgGQp@iPN=jEdpFNY8!QFoiUB4W^#_;du|s=q|ZEpfV=V1eF^ zlHoI6hC6A=sdZU(J-PPOM?N8x6QyIIw3Q` zlZjpB)MT3IS_)?&(s>rBFQH+-&v58ljqJ_lW5^MVQv>{6(wGPt9l|GW`+u3u)z{8_ z1Wz@h?fuP91uV(PT2GyKw0VAXE2GCqrFK-2B`(I_y2u3K_e@*MC!fL=(4TH<+%3E_HzF)E~z`=a=0Ky`&cJ{5gyj zR-A)a8q97S+eD$B4{b$X@0$JNwSt_-`B&^Qpe5j2!mO^Bj@rGERbA&Y3K+=6LwojJ zyZg}o#kHE+eeJ&NUpR{+9!JD~&hHzx**y{OxuA0xpbRMYb^1cZ0}Y@bZ0;EuR_dA_ zs@nR89ibYm*BRV61|=H^T{7QqGG!D;N3#5v)2eu>C)C3w6T02C|w*|B;9)Ug`APn!SS#E zIl%k66u6R({2)Aqky15?oxsfw2U@MO+ceBa_EvA-{OY8c=b#$^H0>hn(o~oXqatwFL z7fK#DllJgPD&%k&pKKMGG&>l_YB3}+m9XWM+9Wtb|Ms2LC5)DFG zU~7-PiYL^&Ivw25LH)^Ez99&BJ)<|O{KnvEzU6-L0jI}7z*F-D6R`;aU(IXepSDkC zu=cS?5IYJ+CwJn8HMJ0jxl;kY#z&qyg+0l`xaItL@%)u8rO99xq?BxDZ-+7!s%LZ_ zB#%>}GBP^;&{UMkfZiyWwk@G|0HVJtVksax`Od_hlkr_Ikna!4QZS!XcWE@=sN-*4 zt@##=b5%@%fZo7*K&jtStDn)t#pA%#@^$>zvL_U}-AUue@gK`Bz_+0JkUwd0(pw)H z`elTkrG3-ELEl~$Ql=@WKAP<{;j=P$NTn1KhA!t&%jXh1*?TIcluGlC$-N88wuum0 zhSktL8sdE}R;F0p%2&qzV$#nzqqdGxNrzECmX)br>=v4fRC!#+LUPm%<-m;)B%;e!xaJ@VcN zFmGIVGa>!xUh$sPqmRY!AaA&5GI96D{z7oxCl$X!(V(mfT&Nk=_0D0dv7Wggq7tA# zF6HUQ^9|3LPn;F6Qh^iv}rw)&!8E6Iv_2L|*$ORYuk z1)l=Pi{u`a`2XxD`JW+1tLbxxh00Q3qSqkr=hkENVmuKa4UG1^KXT#ygZ2xeLyJW`3R3SmQRo;RU;&siR$VW5{n6lr$WSv7cmk zRnIz3{TIJyh6>tUzHod=bq35YZ~xfIn@?S+zv$+ZCx;hi3X}H9>!t;8q$YOVsa;V$ z5WDtf6(G4@E&0%my??OzB%Z>QWwTkCPZ$hX9PXPn%BRJIRWoq%+d*=Pf}r2&fvqOE z>adHO$=Jp9_~#r}Ys%_7k10IUr?};gFzqp~&>&BPIK-ZI@>MmvmkaW0@+SI}8CVS(Hv75?LNd)uBW{`C)w!}uGz zd!&|t01Rl{7TefxcJ}op|0HPeviIrRZf#(x*JwVWTZi|X`}-AE#lUzIZ6(K6AHc|0 zTT}+U12ybzZeqZL?O&KJ`cI!475?eWigdIzHw(do;qLLVKu~w!_Qc16fjVxaYyaTj zfv&Ct{%^y6@STihQ=!$v^@=mCtK!A`E4EHruNdK zH2X#-4`B-Ykk^Ftx({8^Txp38120WmCH3>p;p-}<8yM%tZy$qXfSF@pMBX=|(I^dGwJP38 zghGTM;ScEGdm}iWwk9!N1zwqRjv6w#*IkmX{)Mdn#MgpR`rD9|&QIu`l1d@g$$UP%ujL#$E zBlozDmm^zN;Vm!kMbm=w_$`>GUHIEHcgy~p8-A-El*=!EI(DsOir5CrZh?Ipc)zIgOR~v#+!NC9|5pZ z{Mq`VYgLequ3L^QUQ@f#ccbryefz1vgEZw-x*P7G1CvknCcA&#@2us0r&`HRyb*Z= z7+D8Yrw)KB&S%xepVQY{d~UqLL`xQPz-_9){cf|7y_a3jS;%JX<D1HeDXM{8X%M2&QWzry^iyjZU}cL`m%3eYR9=&!l=U zJV%|P)0XJ;CEC(Qi%p4a%5|nl z%icEfjS#fIjZ-;kwsZM>DmMzW4&?*uE}b(vJ7hdunp?bKetkiOLev9(E}h%y^zEIr zV$2jjrm7&7dU_D`rSkrO;7-3~N}ubw3rHtl|~PY>}q_IaJ&1=x`eq{nJg0&^VCC{5`g=v*&5G8WkFRKduP zR;)#-9($m1jNy}nZ)+UC>lfF#A2xoJyPjMsi#U~&&6h$wC(@}Kvsd7o7_2;nGTYhl za$NXXmf;mw-TBkGjOixspu+@S4SbO}?~!>e;X2gz#QuWw>XPJ4(F({_ zuCj634mJq(liT*Tj94zA(c>H%PsHUMH}JllOFe?IEAcJh&uhP;>r_M|{JmQc-FRCp zQ}dl~u*qfnKQ{ltjjx>3%b2`7kuo*=w|3r3aNl3YPT-P2Jtxer3fot8|JG>E>!j3A zZs+wmm4nIvYBUB=bs7$s(fc`v+B*($*oQjW4}rO&y3XNNj+k+~hoBUJ)6)W8-%G6<4a;XQrj zNL341rDWmLE(7iREl5cL=^q*?jz$6|n`Z;^uBd%(kcD>N_fUQ-*dgT}(UI=v?w-`K zq3zYn6*4k7&(Q-$RT>VOQC&n!)7dP|2`Z(oT;TOP26a$`v#F!wAcu1>mTm%BV~iBC z47cANNHE-%GTsIZ-h`gGGrqbID5RDozHKNONOl|x9Qzb^P6ohnjv<6tY?ac_l=^Uc z#~!X$J<(NrU#JW(4Af@dmuZR^4J0Vf{&8hyYx57V)2+4+#<-pP<8thHi@T`0y54x0 z*|YHP>(L#pKk#A-vTq77 zJNOP@f+sBH|EY0~PKOZyz!9pbHQ)!}hTbdp~-i81H-ZXowI8eS{?dTKFzp-*18kBg-(7O&W#v3FN0D z2JDbD*VXsnfRDZkS#&uJAEZoYka|DE6Hx@)jf4~a*b3phWGDQf4ub7-F1#G$hv&P? z5%?n8bOCwd7dSTwq_1_)`PyYL26^W?5+<@mmymY9#B)OtY`?=%5Xc2$4ZR$f?^^6K z4&C1e;Ybf3_2@|QQ$vSJhe|N1l`1XVD<65uNx#Z-tO`Cli+m6F{tLG(6~6y01RuQ; za}LROf#c$f%+g)RCBIAjG!c&UqOeC7uL^$;$DN0i>F(sb-`#UEf{!k&^d|Ip8-Jd_ zcEJH`=ja;%Tz9z+hp^QbHs`k=oH2EF*Y+%GnQEz6xg;t34(tFs)^(i?o9e3KrJM8g4(5Fn!0 z|5?+OpE%$D01dF@mxjNoNB^Eaw(NuU;h{k$>`6Sx+;gOoj=)yn#ZDuIjj6f6Oop%JQqa51Kr zi}e*IZ^j07Sq|c1$7;a(PUyS!`aM7Axop|yH$_NBxLDtb$@SwO@Ik991f-JVI;y&lM?eSercWRRg0Mx5w!j~Pj@aLe$w*fyp!NEM zm<|Lvl|5@>%2++d84T|U0t)=}?(lnN8q~kd<9G+)hv!sv-Gi=P`2Tp+PPQ|^1Oo_o zlUSnt+X#F+o)NOuBwM$9kp_Wg1qSs1a#zxI7N6G51)Z3-A&%#f8ZrJWgs__+PoaxB zagm6VEoYp7yhj@)s8L(j3U3Peaxq#?-gu-k=oTT)3&uLhg^CuP%<+~eGfT%J0^q=4 zeciQJ=~Hf~PbM0q3L_LTYcAA*HrGc=XTnP-IZ6K14#j-*mEhY-J~_<1M(XZF{O0KF zDB{NIuP%kA;S{79htSCCoM%gCbZJ&!MHvSCZBe*Y@-TA z2e_^ufHmN#MMjG)M-t>+0Lzk`ONN%S1@UNu&^k1l*;T)9{R{bC?FpTAB7>u z*1LO7V(Ota7_d++9GWo!e3CG=E#evOwis?;TU?m?yo7yQGcsGYrP0gU zmdji4wmfzu&*tOcBoUy4#7&JynJ^_GslbL9_wRqvERT$+xCwMoa?%PWFY|Blek`kVF+HeEH5i1VF-VcIi6-|bLiY@>9@;eAwVQ+ zoL63Oi-d@ofm-AuuB1Ds4bpP@1vy*RoxY(WOa)BLnpF;|VOqu=vj)Yq%5=okRCh>m zHrAB&u8U%%rb(OCWu}?o=2N2Pq(oiOhn&lc>HU=@V)Fu+tv5HGb9al`8l(^oIL!w# zjXH4rP$oxPN%t!2#rvc6!0&(nfr37uN#Opz%`B`4Hg*n9u8(ZOHw{OEhJ?<1*vLmh z3Az7YX0T8ZV@Q8Mj$l$p2+Qo)@gqksUFMrxS??w`t2UiE%R6-V;@mJbliYi)ao^Nw zHPbt9%(!~J`R1*rnRi-l?~h$_psn?8-21MC_{8L-lntrx($g|Bv$Jw?^9u3{i;FfM zoL^c}R{r5+_u6%H<}F-MMLSKetexG!`1<7=x2N|6rL){iyAY@5IOhQUVPQi*bC5jCdH4WPsnK~_NF~?VegIr{nP`sbf0E z7J5HE`aCv1-YG-Viigc42LokbQtyu=w$?YQmtvq;2G9W5g@U?g#vWGWjDFV3vMlRlSs%;#Af?;s3B8$RA!zanMKBVn zkqD>heL8z*q}9R9%GMzpJ`rfOch-^Z0D;xzN$p3Zq$u?nm|LvFBy_qwo3#FfyRYsNAlQo-J%FBdk zOFU;)5Pp#y8PQYWfXP+J8UyHmKlf>ye|`$fr<4MqaO{AU&m?aldw&)PRHqUsKYuen zSprw0@g3i(TxAN9b#1k2-A)zQDp`1HzpT7&lpJ@Dc+C6>FV(?5`h?KnE8;ti$j_}Jzx+l- zz7RO7;1Li6SdA2Ms-eM5959h3Jje)`&#bwmJ@?Me0qljR4J-yM642cm5Eya6S>?s> z`Ii*ne-u2zkQ2nIBU(6Ut=cX$iWI^;Z5%7U-OZsw)gIr0GU$*S%IW zy4v{0C$&SzQH--py$G~Jjs4k^o!OiXS({Z^o+Vj$FY1Pr$j3(w@S1Rqgcew+(3rx2 z{!T;Q$CYQP&FwgIEf;bMkXLf+eIU=~wtDL|IhI42vwR{`y^Q==gMnO{{Pan;bV%!D zCl$!9iH;~7kabf6vSK7p7HvJeE+|C8KRyrwIFB0~J zT}IO5V%B1GNKS-I_5yNIDQKlpo4jiQcNCb-Kg*r?G zlVVo^z;Sc-i(z+r&67I-3qfq|vs^%4DebntltUUo@fzSk1hLJC;=DQ8)*3udaFi(4JS zR^&z1X)gws1X1&hXcBE+TF@#OB1;XmfJ^G7C(M>JQp^MtWvz7?)3k6bZM8u0ogPg# z16}6SmqKP?SR^o1dzFlys!Vsj_KQmE)c2vzX8OYzSk_CFZ=O6%zSMV^!K#Ju`w?DUT$2>{-qpByeAH=i}@US z)TdxQE6EDsw(Z6_PBdTRb=+=iI4=8QMRj3T7BLWbC5N>&$aJW6;cd-GxxrH~WEEup zjmq~nlB)Xk5XNGkYO_jV?;AKtO<}#sZbGgz=xZ)vrrLDJ8<9C@plUf>AyOV_{MNAjbW zH(T#-#8Dj0qETKQYV8Z~KxK`#9h~iIQRhxHa%0+3*1HO6$=#*eg-g4poRHP4`ThE0 z{j|&;5vP0&KpJ>sd=I)a4ccD+iB!(`YbBj;kJd7yslRXV_kfvB(5%s0MLGK+YlQao z5@5f~G_|wFh8NrTR76LpFKU?F&#-_e* zf~Sidw%Q{|g)Y7F)iQVm$9EqT-4g?(4PM3{k9ibc_x4gC)JQ)+RMB=bQ~Y$==~AYO$Im7FkHkQAVuChb^!a z^}@*5h@2<;`oZrD|7(&=VUhH}T7qywgb5I?35y7kLPT@mOQt1}t&vm*Ep!}u=y^n# zjM}N4T~_PscAJg%vMOV|soFTV-8SBR_f7EBQxm;Zr@}YiO!LDJ)48F;1Hm9M%nTIB ztQ4ilY*=e4NE99(J_-fnpEQP;m-r9nK+n$jeJr4mVz#1bj5FOjVlq_swyWx-}Gw?80F2_Hg|A)>Mjw;eXlM5GE8 zq$Zn*XO{Vp1r`!oWHC=mEJ0gpHDs+#2uUFT0|jJ|L0!*y2mO4b(09^)Q0OOV?!48K zR0$fX;SX=k3vEGwgQ6axAxXe6%)v@RA`~%IBsB8Sq#Hd^WJJ7ZB`4xdPYN=8$Wjth zF&s-jzO+$O!q4^T4Mz(F^bF8Q=zh@nCXQiU*cEmUzzafAiw_a}rHSm2i(YVqBBZ{t zxQ7HP1S1HvdKL#Pmz-V>kU-K3`~8v?!xSaVQ`)~lnFv-V)ME^jgVPMR%?4wI>h>Yqz>O=UX)W5M|tRHK~%E25M?;0;~m-VlPe9Crj2HRmfShVf68PiLPckx927&Nz zB~rWuYA`JwJtK=c^%^v4;n1yzhu45XK7Ju1qQ;DinGm@(pXtIiwmee)xL8WJK2k|jTc zkRb;@5gy}~Oh!Q4^kG?Y!u8vqluI3Y1LJ8;>gS&C{@37kFPfx0OHl6Ph{=<}SXM+Y zLi#O*S?}$M`fQNwJfj@ynT4zY>1fyQuC0P_X zFRz%*XWx|LRLf(xrk3OArNbfT||L5)c!mIUKNCxz86ZXFRm$~K!lONlX@5sYLM zqZz|kGj7~?CNOc)iup~RGKHy3V>&aKX=V+~W)5?i$9$HujODCgC97C%){a=mdN#0; zO{TKvRr%^Jy@Xft8eG)%4@CkCb++$|iqkcNCWwK&k%kpDJ{NEvoX-VZ$VFUi?jD%J zRHiYV8O+?H%KQQv3o(oIab%Axt*h>9;pQ4YXta{jC8`UI)QCGGiy{}V09LMhZj=^u zqAxKR6AOz3la>^T#DXV<$csnK6g@3=Nc>I`ghZbPCl~U_mjWoHh+{v2ruvqJt->M>XR_C% z0TqFqX5$o@Bhw|V+B<9GQ0@bGER^NnjgdoJI!O1L^njiwcS0Wd6i`SJ#f6H=$OP&P=39}HuMpB>uiHWL9=qjm z9AC4OIhJp&vp5RS-R1(dKL&jCp_ss`spWZ-CMZ3uBf--Zp`-AOGm1(fKArb_aY+$G ze{0U$K;S*Ee^2ndm-H@#-g~N}SJTfyMde`s7*>P%7Z%e3LQZ)pk)jl%_;^>gulcY( zVpmJAICA~`2=>&%ji*Xcyei3uIhRF=q(|7~kM|S~m?xOGLuZ%s-q6=&AY|mQFnp0h zndzu0NL9fC)WJ+DVMZ@udM*UL0IzMa$*a7FQO@O!@=dQ+qt+*1{SI49t1+y(4~8{!5Od`f z83QncGe=UcQJ}wuDICeX&-MIH4}8?%S5zoq(2G$8Nss7$m9?7t`vK>g!M?}hqEd+0 z@s;7_Vpj~7w0P@~$H8hHW0g>OD<#G|ljT<{c%bg97Qg}a{5hE*rOdK3a1P*nUvBR& z06b2-`clNZ|6?%R9K))B9t7m4IsvSTfJhL4C9eings(X#cOU|$ydX^MI5;oM+(Gp=xY!sTPaQdh?b*9W2z^twxM zN=|){RxaunzyAiz1^qneCjouS7tl9<2K^G?|N8s$-**81d*dnk=`v5O0QhmYCr1E3 zuK8rk6XWshKg@D5W7Xz*Z%uE7!2jFYqcRv95)y4T)DDY{={vS3SS>121 zL;6AJ8T7lTEr$k1O`1LJlruVWfmvugr;p2$ndm(4pH7zSv&Vj!?sClzTYPpQGCN&& z(OLib-(`Pn*UL+8+3JhKjyS4|kge~(S*?ucU1Rq9XyT;*y|val>us>nW}A$)+I_cu zV}5Mjg9a_5O(f5Y%@T0b~t8?G0pEu-FHHrT&BAMbNGVFaS^p2!>7+UmyX8 zN+&<+cSE>5kr`WxE1*0r>V`v}EsItzXo+(!n(F3Esnl+Uv!Z2MwqmZ+AOh)}6`FW; zlvdpt8(4G*j~bTFT8*`Pi8MhzDB(esILY~F$Y-Y{sH7dI6LyB3-f4iis9f>7RcIVX zCQ3v-QEQAO4UnSRE^=h-u8=R_8X8+tS>ohwGVP0XU{_yU(HyI>MXI{|pLA@A53ZFzn}t_-9=0l6Q%k7JZN%av23W&3N+i1 zgrOiPXGa{(fRPLdRDtRcFhCr&Fl#&v{B+I!!x#XC4IsS00Nmc2pb%V0RIb?oxQ~0t zBiH8)F|tW$|4M()>z*WF?<1a1ykRaJVZ`7`gbzf5A~*)MMNue5MV2iZkvBLY;e=(T zny90&WrKv1E4ZwY4OL#JiJnc{NoX5{w844678|z7f=p?M5L;MLe#(CVu46!kV1gp{ z`K`ApLsP#EA>PA%U*%iWO}!-E5r}~k500kp&E&E*iyDjuF)~a~IbjtiPj-c@LgyMX zq|Ho#vm3lDEf|PAClXd`!=rJjKe&)&HjqczcbK^k$0kMn9f=~g2;)E^WwXf!-Vv5@yYqQlj%S2uZXf0({K_&a3{&!e(_w zUynnes7c{ZHp|!SD)Q)MX@X4#-i$eV4CTW(ApvuD=LxKIP6qVLvdOQk4W`2paGZ|o zHI@vY3Q3bTRNj@3IuN(&cUHV??Z$=x0p(iD(_7nzJCcS%_XN)VeOT;v+WZ#-akWXTijd)A~F<9a2rKydet~oGFd~8=6%S2Hqng)CEOdh%4-u59G7KK#*gTIy1;+ z&pkO%Nu~`Fczu0;Zxmt#=GPRcCf@!fUc`1$uorg|R>!ik^Q+ zkta*_s?e+mBwMHqJcJ;E0%o2SM?5aVV7x{|(hv&JPyxWXBr2jtt8Mp3{!W#0iEpye zZRzX}{5^W$5K?eP%24p8KfhWO2KtjKH(V;U-iJm2Z0?!K>C4xJ9SP4?4~ICJ#)QP4 z4InjcF)}kgy?-;`rAy$#6>STx(CzwH)f?zHJD zSM@|wCxIFDkyXvIRjXIdtnrY+t(yJgSh1u^JIJeC>R2_aQaMmp<6Od9`cXWvQV+ z?C$i5PBQ{@si-^r+23#SS}!(*mABwtA6sH|GinW1)@Rkb zZpMWUh!~og5F=TuDEJ@DdFoZp59;&)%%k$a_l9FwGr14GU7~N`GPa_L$=a>xBr@x# zant+$s@DK7wRbO@5~$iPQb=U#jWk$i<<{WMaa^jjFUZM}AZ91q>RojZ%TAmP;DR0K zt&TDhuy>v@J|K8aEiqWk#78?4141X=@Ya9~l=-O#1N+t>$`9r~t-aNUN*gtJm0o#R zj?$~uii7252CQ^f>B&}ubcAnXu;r(E!xfpFC${UoF>WyzaW!1nbJ563GJwsLmCQ>R zIke$)d>x`zuEj!m->RWiw^^i!0Bz1}`wlp&(TL}Qk2YxUK@@R?R~mJoITIjQ^xNnD zG>3WUhpfgt<}eGeSXA2gxy!)YX;1I3IV$Tmt_}A8HCnlQ3JRmMD+ezpXN+p~s_sgu{8EHI9wd;*# zyQW$)#5#BAf`O`+b`~3)Th~P4WlPtQQ~D~ZI(a`e)?WGW=FPTO4y8U`Z@x}plJNGQ zIJ#Vg*`1m|SU1fs+6lDb6j7FcE4Acq6KVEs5iwC!le!dls!%oSwI*x#L2C+HOin^3 zVv}6bY`DItLWT~$*$>Qps;haLlS@`bAVfh(OLOo(Sf!6;gS%k{9%@q@-tsEVl5TR> zSqO3JTS+8S%KK4S;6tS~%Jm*jfIkhq)hfTdBh|S3fLZsZ&zrqjn>19d-Mb09ra{fv z&``Z@Uyf1Pw`%y|#wx$bwFg%oymH8az+VbE09)aZJ})stidt{C(X^j)#()KY&6d;< z*Sjccy1rqFCPTHI)bH#Sv<6-8y`4+th;}V`%w}v~>*Pb)7ggT^cyPa~#)SFT1Z9YvGIuIe?2GL?HqV@y*ys=S&Ni;mGv z6cn|JF$S_QQ%Cmix?`8Wr&E)&v*T$~D22Ahps#5(-(#w2(i^mPIW&bGpPhxAN8WpI zZtwK)4k$-qyQvi-B0=32qPfR3Q1@5wZ5uvaoKid;5~V4n<2$ngP#9l)cmSS;xgstr znHP>mk*GcF9}b5tAP@RH_s-H5cJF=P6hefrfL2v_YCYNmJ-4#Hvb`M|ZF}DjwZG~1 z*Daymsq0A43NwA2XgGVO?*%)C&9#lj2aF`lrn(^@ZLlN5PEnNu#;PUV^68;E0iyf3 z^K2A*p1Uvma%iNGzb4df=aC3fH*l=*;2yTPz8bBRS|5bAJjD2F`t^dn#O3tur?@Z! z#SrjL0THHlyjnMi5T*!9t3soN#SCKd*H@i}EH|&BMx~Oq1$Y)qL;yPx(iDmI=>knQ zTaG{9yy!G%1^Dt>l}gn;QUSiyu0;vaH7#g?X&O_gBKFJE*HlK6PG{MwYtxtQsz{{( ztUmZeV&08CDv4wV#@e6Qp>aUXjxSLllpV{|m#uzCY=B~|`&!$=Eq_BhWkFz(T9{3O zWUJuUPd`=X=b4Z)FEwre&K<3LarTk-XV2e`5%3rzt}o;1g+iXTsiZ1FC|%)k-|rcs zK4NR3s(QEdbUscZ&^!&4o1_id(np^py2P-AkJ?r`QCN5Y1Th)|I}?({R^qA9*_u_h z%q5X;sm0ZfAhYW6ORQ!i%7~by10Wj&V~i20rs>1h0$P{R;NmNq7PfAdKh304pD9ss zQCte1p;q6uZ7X553XN+43`XkC?<kC4kU=RD6JK`9+EyS{DzTb0C@o@4DWevpDeeqNU5RHv+lr=H zk|q(iLqUs^s``ZzC8GQL_X2i6=O4ZBpsl6eZ_1364ri5> zwd56y^eLfn>aMP>w!Io60t27eJyg) zYO`r{NUix|jnM{h7u5OD%$)R_6~JgPal6|T>TEd20-YtN2s9F@M6ZxS+i{4-8>uuJ zhbrJ+4N6nd(J6W8<~{$G;ZwGTK?tU1Yo*p>gtlX=tguS0mvd3%-|9+cXFnUo^@gwC z^?DFT;kHn!ZA!FO?Hs89;klMWH;#V25(!7JWe8wB4D0pP zs4Bf$OmvKnA5VN7>AD*_k?)`=Ttb=Cj5aFAkYEmZdE(K@wfRASwtG^#7pDXA|7(mzV2q*of%U;@7UQNj18aFOuu zi@#mGJde)-OpJYut8G)Dd8AS|)c?kd40NTcZkSV3V{n>RC7SW#^P|v6Hi?H;XxqAK z*KE<5MXYp|QDo4Y)=<>eN>w^GBb{MjD-?zyFc2^?RomFT8m&PuUsqeImGw91)%{g= zOl(bqjF(a==~YIRQfX~grBhNrg1gt*2{y^*ewS~&kn}dGaNGwx)&u>nhych>GI0g5 za40%dNUTUYJE-K|QXGUUy|)C)R1RQu*U%2^Tn`?xBBx_{u?bQ^(C7EXc4Bvu=Lg>k z1A_rDG~f#@L_^929#>?2({Y;*P_Gl>1fcdW$CoGiGzxNm+Z@KwoeYMPPHPCur~x!T zs4eGk$eU<26q=ew7chrHA&~Rc+uPq(+)Q%hoGY(ye_e6uMWg*34xC>V2>Tzzw>GZ2 z&Eu-Lv}{*aS>9BRPSZCeG8zIxWxGtqL|_hiWB%KDko+cX3g0nEr}BR*vO)HdX=N%kh0aiw`51#i&99p>pgJ4=J`99cX0Q4DS9TZd9=v>I@Rq#z6Q2Gj zCd}T(NRxz{OHP6!U;Lks#Fs>BYH3+p;y zu^sZ`{Px3(i0{?#*KB^Z1xCZy1fO5I1D+S)jiQ4kY>+a4Y0bcrlqR`cT;IWv`8`eS z`?!EPZ&ar#=N5RZ&-lxtzc>b8`_=D%pg+h*jPNY*W z6#^G``$wmZo^YK4MZ4X(KTbEsFkp~GO(sdit@fEj8F>4HnF zlJPfAQ12-$e3sy94%8x6pS5|0SVH7+3ik)8Z*?_O6>>9Wf{^=2`Z8f|tFdUc>Rs$% zu3!NOFbEi=R7Vh$MecXWpCN0C2O!86AL=eHcLvKAW|5~tPW8@+#ug}lutd#GF_`;RTYem7uX3}Gp3>tKTP5f}_TqBZD4he&4dZDdUhSkouo1Ff}BKxq?$ z)|Z`uSKK9VCq~)G;HkE6DckY_tt>&x!9$~`o}Mf&MgLmrDanKU72=>Dm1f3ujTPyQ(tMQsuY{5B&|67 z{p6y5G^e}vx|)|{+k>$t#v-jR60q?wCCNn33?2?SKr|3ZfSKn8X9b- ztu3zZdJ7}3q9TtmUk-p@Fg+n(E7pf*rHYuR$)u2}9@}L#7TmF4#ZD7&baslUX|Tnb z=R@b*(2z>}c=CzcTeIwTSdhJMIyc?o>_WpEQd971W76*p6jagOyOIurf7N(}LnLmK zpktwI8>% zwOK~@5AEYdN_{~#%YXu3Y?3f!xw+ihQRb$b80-yCCiv^&)J}g4ONSZlfASvaRjlk@C02ss)G zH3ud%Jg3Q?C!GDiEJ+3LU%NZ3c zgFk01E`%2rM=tijSr$GQ277kf?K`F(fNy$=l7NR5z4}2JR9Xs^jZa-V@d7P6?mv6) zlm+;c=l7l%9Z`B4)F1Soyo>0YV({nM>4osZ^vETUc{btjl-mzvuUs9oZO=pB4+#CJ z>lkfSt`dpvXJ7wMEJ9weNxmB2_!(OlIC6G}D12cZtnTKRX-p%R!!$&d6Ho&&=CK_F z4T1Qd1BIBT0!(73(b@B8jCzndu6nK}u3nE{S5QiRC@cG+r1XPuf**8G)Tit2g{Na%r3sR{ zV_POq;F#g5Av=&tDfym4`M#v|{c!5{Zv>|SO>^^xVJ^rzk^TJh6LxM8rK1VDqqpXr zp89=#O^wHsNZt0u*caPU6MHzpYhB=CrnOeWA@CkNhkdhW8LL*T*V|2EmG!)dJ@+7w zz_CjZOHmmFl@-lxNJoiZWMzG(Cf?XRF*jip6FYEV_C$+vTU6ILF_RcBn1+-JM9q1R zsAoQ)S2dapRaONwT@cy6`_*6EHn;V-JDZe(c}|bhNk|fwR)UYez1$dNHB%&3JV9*Q z?!V%Le}vPS7|Z!7>%gm~c44OEC?hQtP|$hpFnX$FGB2Yt`INW8KfCyTs7iPQk6#0UOq@zj5@ zDFu2C=7`DN%h3CPdmfcPS)LD;GF3L&Kvx=Tm_WpB#$(;;B9X1Ky1H4b(>3d4K4pth z^8yK#n~Ne1mkpT0VX@Xm%JDn80M`zQ-{00G{ohJg^!F&}@e;dFV=sKGcORa4mC0OY zu^wY_zM|={*TtCY5)2Rpyx(O%6uvx<%b%d<TA_~nk!0`{%!ih&&uJ{Qdc zXYl0 zWb^W|#dEx}8GRA(5w*24D1OH5}!DN*V{8l!7yBPmtQDU>Q$3Xw@{`Y78ZbHFF zm?NqzobZ;^$IPLsg<1U1=ogz_MZizJVrtVi zh2Y5yq}Hz27$$+F27^YQW408n7uorkpPYKAof_!^cx~T-(W3=jr=Miu2`6X^)QU`& z^67`f<61FRSTTcu?HYfo&cDnxVCNe#}yWg3MpS{A13xc`iY(x6+0 z+}y-Gke5E04m_jA<=)Y~qnr`;)K;}eC9e(*JBul`+8uj^|1`tvXO}L4!8(n7aXM{R zhgyh?PaiXC76#+q!+_d^C%@QbuB;T+=rYvCWpch!>4#i(U#)z>4C#@sk_fAGCGN+S zY3FLxy6Oa+gQn)=S$KZpv@uwFcQY7K?bzzUcFC*5hp8^tU@Q=wcjias%bfPvv}rrL zF%S0c(lH-Dg5g{YjJuCO#)D@PjXA{*cP*tbTDRa-+)}!~GNL-l@Zj*p@mxD>r8(TH zTNQ504cil3dvGIwwUhkK)^_j>z~o(ySgww5*5dhz-zkZ&ZBPwR-8CNFR^$2+eU_S3D=z%PAsc4t~gQ= z-$JqO*+@|l^Wf>e8PTu0|C@{a2haeE{4(SI>2CV_|Hp=R+S}{7O97! zVh32~uC}@+Sj@$Bf}2QCwtRmd^Bx;+4wG4#s3mq{lt7&USVyt%Z1o)FX>hLqV_T0_ku zN(%j`=NFwjWOCAU`FK~xa*?#GE99w5tMbCRK_gqV_e9;24n1N-QdN|twCu$yj#wf~ zR|bcAx;EKrQ>674%tU5OBC-$;>D0nTa`$AHQ;!Ab}MO&{w=5-ASlB@@U*z;&EWDnLo(iOjx{qN zcXS>M4p=?a$lh|hN+Yeah=*Zy8dHvBQ(+})82OZKd~)_gn;yNPkL`Vu%n?~1^?l=L z3xpC_q}UyzCl1LI!qm`i6$N|TozF_MTSQN5br=@KUBJ6V^afIrDNh(vgK}@95EXM5 zsvC0c4z>4gbbBN{EUc`=pC#+*vV_a~GjzpZ4W#kGXHd?% zz$MC$7SBc>-3muLNA4p@i6CfJ$Hcb@?4Q{C7Q93{9tD6l>q%9nr0gYA#9w6-PqN=F zC(}2w*{=k#x1%bMflu5jjia#zTfkgg_k8 zC(OGN5id9v_ws4HM7VqFTrKD!>P1giu8Tof&yCeVNHb z!>!hWjR3Vaw>{aPH#%ZiFV2SIAV02td_%EUkAOivxmZFANaZXp*lIwA4l@VWSFoCG z)&MC)0KssHq(No@mr2rtN6k7Wo3LN(jz*1%5i9PJNy6ZlZenJEu(GLPS9=j9{BGX0 zTeNCB{|=oOX%fEvzsz_Ex^&AVBGcWsnqu%Aa@YX}U2u^n4}LVZBdw>i&MBu!?X%x` zlR1tBY{;;P5u>8UY&C8|%vpO)HAdWaIn$rRH~GXv!$z6y&>bOPT$sYN%p$yo>}gD=#ewd9!f~1>4s_9j_dh0fn>{(D^I=xo8c8AAR;M3 zR;)y+GL$e`F)Bw3Z6F3F7B&tp9zFpf5wQxDs#KGZl95wTQUQoCp^OWuw6Tus`G%=a zu{9z{lBO=Br*Amo>I?dNHx389JRV$aI}3UWGmotn@q|1+HWB~CRQ8}*0BiaS1(ga` zIvR$5pu;zGuIgH~xj#ykF~68SM+ki?yiM8uu) z(;1COc01gumT-7cV_<;u!%Wy~5nuYCW?Yer{TqD7N7_cf5mri2c^GF71j6`7%(9xj zSjD)H!gzwg!ModBcAd GLCRiVB40oN 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.

-
- -
-
-