diff --git a/VERSION b/VERSION index faef31a..a3df0a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.0 +0.8.0 diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py index 5464853..0bea1f3 100644 --- a/backend/app/domains/products/__init__.py +++ b/backend/app/domains/products/__init__.py @@ -35,11 +35,13 @@ from .service import ( # DOC-3: the downloadable worked-example CSV the BFF serves at /api/products/sample.csv. SAMPLE_CSV_PATH = Path(__file__).parent / "sample.csv" +# DOC-2: the column reference the BFF serves at /api/products/columns.md. +COLUMNS_MD_PATH = Path(__file__).parent / "columns.md" __all__ = [ "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "PreviewStale", "NothingToApply", "RunNotFound", "EmptyCatalog", - "MAX_DATA_ROWS", "MAX_FILE_BYTES", "SAMPLE_CSV_PATH", + "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", diff --git a/backend/app/domains/products/codec.py b/backend/app/domains/products/codec.py index 52ba6ec..41b476e 100644 --- a/backend/app/domains/products/codec.py +++ b/backend/app/domains/products/codec.py @@ -6,6 +6,7 @@ from __future__ import annotations import csv import io +from .dialect_shopify import is_shopify_header, map_shopify_header from .errors import FileRejected from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row @@ -13,8 +14,8 @@ _REQUIRED_HEADER_COLUMNS = ("Handle", "Title") def detect_dialect(header: list[str]) -> str: - """The INV-17 seam: SLICE-8 recognizes Shopify's exact header set here.""" - return "canonical" + """The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1).""" + return "shopify" if is_shopify_header(header) else "canonical" def parse_csv(data: bytes) -> ParsedFile: @@ -31,19 +32,34 @@ def parse_csv(data: bytes) -> ParsedFile: except StopIteration: raise FileRejected("not_csv", "This file isn't readable as CSV.") from None header = [h.strip() for h in raw_header] + dialect = detect_dialect(header) + + # INV-17: normalize the header to canonical names at the boundary. mapped[i] + # is the canonical name for header[i] (or None when that column has no + # canonical home); unknown is the not-imported warning list. Canonical files + # map to themselves; unknown columns are warned exactly as before. + if dialect == "shopify": + mapped, unknown = map_shopify_header(header) + else: + mapped = [c if c in KNOWN_COLUMNS else None for c in header] + unknown = [c for c in header if c and c not in KNOWN_COLUMNS] + for col in _REQUIRED_HEADER_COLUMNS: - if col not in header: + if col not in mapped: raise FileRejected( "missing_required_column", f"This file is missing the required column '{col}'.", ) - # First occurrence of a duplicated column wins. + + # First occurrence of a duplicated canonical column wins. col_index: dict[str, int] = {} - for i, name in enumerate(header): + for i, name in enumerate(mapped): if name and name not in col_index: col_index[name] = i - known_present = [c for c in col_index if c in KNOWN_COLUMNS] - unknown = [c for c in col_index if c not in KNOWN_COLUMNS] + # De-dup the warning list, order-preserving. + seen: set[str] = set() + unknown = [c for c in unknown if not (c in seen or seen.add(c))] + rows: list[Row] = [] for raw in reader: if not any(cell.strip() for cell in raw): @@ -55,11 +71,16 @@ def parse_csv(data: bytes) -> ParsedFile: ) cells = { c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "") - for c in known_present + for c in col_index } + # Shopify grams carry an implicit unit; canonical needs it explicit. In a + # Shopify file "Variant Weight" can only come from the Variant Grams rename + # (a canonical-named Variant Weight would have vetoed Shopify detection), so + # weight and unit move together: a value -> "g"; a cleared grams (present-but- + # empty) clears the unit too, never leaving a stale unit (§6.5.1). + if dialect == "shopify" and "Variant Weight" in cells: + cells["Variant Weight Unit"] = "g" if cells["Variant Weight"] else "" rows.append(Row(line_number=reader.line_num, cells=cells)) except csv.Error: raise FileRejected("not_csv", "This file isn't readable as CSV.") from None - return ParsedFile( - dialect=detect_dialect(header), header=header, unknown_columns=unknown, rows=rows - ) + return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows) diff --git a/backend/app/domains/products/columns.md b/backend/app/domains/products/columns.md new file mode 100644 index 0000000..e3cd2ba --- /dev/null +++ b/backend/app/domains/products/columns.md @@ -0,0 +1,95 @@ +# Product CSV — column reference + +This is the complete reference for the product CSV you import into and export from +your store (SD-0002 §6.5.1). The format is **canonical** — a clean superset of the +Shopify product CSV — and a **Shopify product CSV** imports directly too: we detect +which one you uploaded and map it for you (see *Shopify dialect* below). + +You don't pick a format. Upload your file; the preview tells you which format was +recognized and shows exactly what will change before anything is applied. + +## File shape + +- UTF-8 (a byte-order mark is tolerated), comma-delimited, RFC 4180 quoting. +- A header row is **required**. `Handle` and `Title` are the only always-required + columns. +- Up to 5,000 rows and 10 MB per file. Split larger catalogs and import in parts. + +## Row grammar + +Consecutive rows that share a `Handle` describe **one product**. The product's first +row carries the product-level fields (`Title` is required there). Each row may carry: + +- a **variant** — its `Option n Value`s plus `Variant *` fields; +- an **image** — `Image Src` with `Image Position` / `Image Alt Text`; +- or both. + +An image-only row (just `Handle` + `Image *`) is valid — that's how a product carries +more images than it has variants. + +## Updating: blank vs. absent + +- A column **absent from your file** is left untouched on existing products. +- A cell that is **present but empty** clears that field (resets it to its default). + +Every clear is shown explicitly in the preview, so this is deliberate, visible +behavior — never a silent surprise. + +## Canonical columns + +| Column | Level | Required | Notes | +| --- | --- | --- | --- | +| `Handle` | product | every row | Identity: lowercase letters, numbers, and dashes. | +| `Title` | product | first row of a product | | +| `Description` | product | — | HTML allowed; sanitized on import. | +| `Vendor` | product | — | Free text. | +| `Type` | product | — | `standalone` (default). `kit_virtual` / `kit_assembled` are reserved; any non-`standalone` value is a row error until kits ship. | +| `Google Product Category` | product | — | Taxonomy string. | +| `Tags` | product | — | Comma-separated within the cell. | +| `Status` | product | — | `draft` \| `active` \| `archived`; defaults to `active` on add. | +| `Published` | product | — | `TRUE` \| `FALSE`; defaults to `TRUE` on add. | +| `Option1 Name` … `Option3 Name` | product | with values | e.g. "Size". A value without its name is a row error. | +| `Option1 Value` … `Option3 Value` | variant | per variant | The variant's identity combination. | +| `Variant SKU` | variant | — | Indexed; not identity. | +| `Variant Barcode` | variant | — | | +| `Variant Price` | variant | — | Decimal; no currency symbol. | +| `Variant Cost` | variant | — | Decimal. | +| `Variant Weight` + `Variant Weight Unit` | variant | — | Decimal weight plus a unit string. | +| `Variant Volume` + `Variant Volume Unit` | variant | — | Decimal volume plus a unit string. | +| `Variant Tax ID 1` / `Variant Tax ID 2` | variant | — | Opaque references. | +| `Variant Inventory Tracker` | variant | — | | +| `Variant Inventory Qty` | variant | — | Integer ≥ 0. | +| `Variant Position` | variant | — | Display order; defaults to file order. | +| `Image Src` | image | — | URL (or a store-hosted URL on re-import). | +| `Image Position` | image | — | Integer order. | +| `Image Alt Text` | image | — | | +| `Variant Image` | variant | — | URL for this variant's specific image. | +| `Component 1 SKU` / `Component 1 Quantity` … `Component 10 …` | variant | — | Reserved for kits — a non-empty value is a row error today. | + +## Shopify dialect + +If you upload a **Shopify product CSV**, we recognize it by its header set and map it +to the canonical columns automatically. The preview labels it *"Shopify product +CSV — mapped"*. + +**Mapped — renamed** + +| Shopify column | Becomes | +| --- | --- | +| `Body (HTML)` | `Description` | +| `Product Category` | `Google Product Category` | +| `Cost per item` | `Variant Cost` | +| `Variant Grams` | `Variant Weight` (in grams) | + +**Mapped — same name:** `Handle`, `Title`, `Vendor`, `Tags`, `Published`, `Status`, +`Option1–3 Name` / `Option1–3 Value`, `Variant SKU`, `Variant Barcode`, +`Variant Price`, `Variant Inventory Tracker`, `Variant Inventory Qty`, +`Variant Image`, `Image Src`, `Image Position`, `Image Alt Text`. + +**Not imported — listed in the preview as a heads-up, never silently dropped:** the +Shopify free-text `Type` (canonical `Type` is structural, so we don't fold a category +string into it), `Variant Compare At Price`, `Variant Inventory Policy`, +`Variant Fulfillment Service`, `Variant Requires Shipping`, `Variant Taxable`, +`Variant Tax Code`, `Gift Card`, SEO fields, every `Google Shopping / …` field, and +market/region price columns. These have no canonical home yet; your other data still +imports. diff --git a/backend/app/domains/products/dialect_shopify.py b/backend/app/domains/products/dialect_shopify.py new file mode 100644 index 0000000..baa48c7 --- /dev/null +++ b/backend/app/domains/products/dialect_shopify.py @@ -0,0 +1,95 @@ +"""Shopify product-CSV adapter (INV-17): detect by header signature, map to canonical. + +The mapping is the §6.5.1 contract; the exhaustive table is pinned here and +mirrored by tests/test_products_dialect_shopify.py + e2e/fixtures/shopify-export.csv. +""" +from __future__ import annotations + +from .models import KNOWN_COLUMNS + +# Shopify header -> canonical header (renames only; direct same-name columns pass +# through via KNOWN_COLUMNS). Variant Grams carries a value transform (see codec). +SHOPIFY_RENAME: dict[str, str] = { + "Body (HTML)": "Description", + "Product Category": "Google Product Category", + "Cost per item": "Variant Cost", + "Variant Grams": "Variant Weight", +} + +# Canonical-named columns Shopify uses differently — must NOT pass through. +# Type: Shopify's free-text type vs canonical's structural Type (§6.5.1, decision D). +# Variant Weight Unit: superseded by the Variant Grams -> Variant Weight transform. +SHOPIFY_DROP: frozenset[str] = frozenset({"Type", "Variant Weight Unit"}) + +# Columns whose presence proves the file is a Shopify export (canonical never has them). +_SIGNATURE: frozenset[str] = frozenset( + { + "Body (HTML)", + "Variant Grams", + "Cost per item", + "Variant Compare At Price", + "Variant Inventory Policy", + "Variant Fulfillment Service", + "Variant Requires Shipping", + "Variant Taxable", + "Gift Card", + "SEO Title", + "SEO Description", + } +) + +# Canonical-distinctive columns — names Shopify renames away (so a real Shopify export +# never carries them) plus canonical-only columns Shopify has no equivalent for. Their +# presence proves the file is canonical and VETOES Shopify detection: detection must +# lean conservative, because under-detection is safe (Shopify-named columns are warned +# as not-imported) while over-detection corrupts (canonical Type / weight-unit are +# dropped). This closes the misdetection hazard — a canonical file that merely contains +# a stray signature-named column (e.g. `SEO Title`) is never misread as Shopify (§7.4). +_CANONICAL_SIGNATURE: frozenset[str] = frozenset( + { + "Description", + "Variant Cost", + "Variant Weight", + "Google Product Category", + "Variant Volume", + "Variant Volume Unit", + "Variant Tax ID 1", + "Variant Tax ID 2", + "Variant Position", + } +) + + +def is_shopify_header(header: list[str]) -> bool: + """True iff the header carries a Shopify-only signature column AND no + canonical-distinctive column. A canonical signal vetoes Shopify detection so an + ambiguous or hybrid header is treated as canonical — where Shopify-named columns + are warned as not-imported rather than silently remapped (never misparses, §7.4).""" + cols = {h.strip() for h in header} + if cols & _CANONICAL_SIGNATURE: + return False + if cols & _SIGNATURE: + return True + return any(c.startswith("Google Shopping / ") for c in cols) + + +def map_shopify_header(header: list[str]) -> tuple[list[str | None], list[str]]: + """Return (mapped, not_imported): mapped[i] is the canonical name for header[i], + or None when that Shopify column has no canonical home; not_imported lists the + original Shopify names (in order) that were dropped — the preview warning.""" + mapped: list[str | None] = [] + not_imported: list[str] = [] + for raw in header: + col = raw.strip() + if col in SHOPIFY_RENAME: + mapped.append(SHOPIFY_RENAME[col]) + elif col in SHOPIFY_DROP: + mapped.append(None) + not_imported.append(col) + elif col in KNOWN_COLUMNS: + mapped.append(col) + else: + mapped.append(None) + if col: + not_imported.append(col) + return mapped, not_imported diff --git a/backend/app/main.py b/backend/app/main.py index 21aefb7..99f6583 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -450,6 +450,14 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = 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. diff --git a/backend/tests/fixtures/shopify-export.csv b/backend/tests/fixtures/shopify-export.csv new file mode 100644 index 0000000..b650948 --- /dev/null +++ b/backend/tests/fixtures/shopify-export.csv @@ -0,0 +1,4 @@ +Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status +star-tee,Star Tee,
Soft cotton tee.
,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active +star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00, +star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,, diff --git a/backend/tests/test_products_codec.py b/backend/tests/test_products_codec.py index 4880f5c..d1b82b2 100644 --- a/backend/tests/test_products_codec.py +++ b/backend/tests/test_products_codec.py @@ -67,3 +67,29 @@ def test_missing_column_message_names_the_column(): with pytest.raises(FileRejected) as exc: parse_csv(_csv("Handle,Vendor", "mug,Acme")) assert "'Title'" in exc.value.message + + +def test_parse_detects_and_maps_shopify(): + data = ( + "Handle,Title,Body (HTML),Cost per item,Variant Price,Variant Grams,Type,Gift Card\n" + "mug,Moon Mug,Grey
,9.50,18.00,300,Drinkware,false\n" + ).encode("utf-8") + parsed = parse_csv(data) + assert parsed.dialect == "shopify" + cells = parsed.rows[0].cells + assert cells["Description"] == "Grey
" + assert cells["Variant Cost"] == "9.50" + assert cells["Variant Weight"] == "300" + assert cells["Variant Weight Unit"] == "g" # synthesized + # Type (free-text) and Gift Card warned, never mapped: + assert "Type" in parsed.unknown_columns + assert "Gift Card" in parsed.unknown_columns + assert "product_type" not in str(cells) # Type never reached canonical + + +def test_parse_canonical_unchanged(): + data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n" + parsed = parse_csv(data) + assert parsed.dialect == "canonical" + assert parsed.rows[0].cells["Description"] == "Grey" + assert parsed.unknown_columns == [] diff --git a/backend/tests/test_products_dialect_shopify.py b/backend/tests/test_products_dialect_shopify.py new file mode 100644 index 0000000..3c4e570 --- /dev/null +++ b/backend/tests/test_products_dialect_shopify.py @@ -0,0 +1,121 @@ +"""Shopify dialect adapter — detection + the §6.5.1 mapping contract (SLICE-8).""" +from pathlib import Path + +from app.domains.products.codec import parse_csv +from app.domains.products.dialect_shopify import is_shopify_header, map_shopify_header + +_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv" + + +def test_detects_shopify_by_signature_column(): + assert is_shopify_header(["Handle", "Title", "Body (HTML)", "Variant Price"]) is True + assert is_shopify_header(["Handle", "Title", "Google Shopping / MPN"]) is True + + +def test_canonical_header_is_not_shopify(): + assert is_shopify_header(["Handle", "Title", "Description", "Variant Cost"]) is False + + +def test_ambiguous_shared_only_header_defaults_canonical(): + # Only columns common to both dialects -> not Shopify (safe default, never misparse). + assert ( + is_shopify_header(["Handle", "Title", "Option1 Name", "Variant Price", "Image Src"]) + is False + ) + + +def test_map_renames_and_passes_through(): + mapped, not_imported = map_shopify_header( + ["Handle", "Body (HTML)", "Cost per item", "Variant Price"] + ) + assert mapped == ["Handle", "Description", "Variant Cost", "Variant Price"] + assert not_imported == [] + + +def test_map_drops_type_and_weight_unit_overrides(): + mapped, not_imported = map_shopify_header( + ["Handle", "Type", "Variant Grams", "Variant Weight Unit"] + ) + assert mapped == ["Handle", None, "Variant Weight", None] + assert not_imported == ["Type", "Variant Weight Unit"] + + +def test_map_drops_shopify_only_and_market_columns(): + mapped, not_imported = map_shopify_header( + ["Handle", "Variant Compare At Price", "Gift Card", "Price / International"] + ) + assert mapped == ["Handle", None, None, None] + assert not_imported == ["Variant Compare At Price", "Gift Card", "Price / International"] + + +def test_shopify_fixture_maps_exhaustively(): + parsed = parse_csv(_FIXTURE.read_bytes()) + assert parsed.dialect == "shopify" + first = parsed.rows[0].cells + # renamed + assert first["Description"] == "Soft cotton tee.
" + assert first["Google Product Category"] == "Apparel & Accessories > Clothing" + assert first["Variant Cost"] == "11.00" + assert first["Variant Weight"] == "180" + assert first["Variant Weight Unit"] == "g" + # direct + assert first["Variant SKU"] == "WG-TEE-S" + assert first["Image Src"] == "https://img.example.com/star-tee.jpg" + # not imported — warned, none leaked into canonical cells + for col in ( + "Type", + "Variant Compare At Price", + "Gift Card", + "SEO Title", + "Google Shopping / MPN", + "Variant Weight Unit", + "Variant Inventory Policy", + "Variant Fulfillment Service", + "Variant Requires Shipping", + "Variant Taxable", + ): + assert col in parsed.unknown_columns, col + assert "Variant Compare At Price" not in first + + +# --- Detection robustness (review findings #1, #2): bias conservative ---------------- + + +def test_canonical_distinctive_column_vetoes_shopify_detection(): + # A canonical file carrying a stray Shopify-signature name (`SEO Title`) stays + # canonical because it also has canonical-distinctive columns — no misparse, + # no dropped Type, no corrupted weight unit (review finding #2). + header = ["Handle", "Title", "Type", "Variant Weight", "Variant Weight Unit", "SEO Title"] + assert is_shopify_header(header) is False + + +def test_dual_named_file_stays_canonical_and_warns_shopify_name(): + # A malformed file with BOTH `Body (HTML)` and canonical `Description`: the + # canonical column vetoes detection, so `Body (HTML)` is honestly warned as an + # unknown column rather than silently shadowing `Description` (review finding #1). + data = ( + b"Handle,Title,Body (HTML),Description,Variant Price\n" + b"mug,Moon Mug,FROM_BODY,FROM_DESC,18.00\n" + ) + parsed = parse_csv(data) + assert parsed.dialect == "canonical" + assert parsed.rows[0].cells["Description"] == "FROM_DESC" + assert "Body (HTML)" in parsed.unknown_columns + + +def test_real_shopify_export_still_detected(): + # The conservative veto must not break a genuine Shopify export (no canonical- + # distinctive columns present). + parsed = parse_csv(_FIXTURE.read_bytes()) + assert parsed.dialect == "shopify" + + +def test_shopify_grams_clear_clears_weight_unit_together(): + # An empty Variant Grams (a clear) clears the synthesized unit too — weight and + # unit move together, never a stale unit (review finding #3). + data = b"Handle,Title,Body (HTML),Variant Grams\nmug,Moon Mug,x
,\n" + parsed = parse_csv(data) + assert parsed.dialect == "shopify" + cells = parsed.rows[0].cells + assert cells["Variant Weight"] == "" + assert cells["Variant Weight Unit"] == "" diff --git a/backend/tests/test_products_endpoints.py b/backend/tests/test_products_endpoints.py index dbab21a..66de184 100644 --- a/backend/tests/test_products_endpoints.py +++ b/backend/tests/test_products_endpoints.py @@ -95,6 +95,17 @@ def test_sample_csv_imports_clean(fresh_db_url): 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() diff --git a/backend/tests/test_products_invariants.py b/backend/tests/test_products_invariants.py index 3f9db0f..7e045cb 100644 --- a/backend/tests/test_products_invariants.py +++ b/backend/tests/test_products_invariants.py @@ -77,3 +77,25 @@ def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypa 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/docs/products-domain.md b/docs/products-domain.md index ce3f8f3..3f55409 100644 --- a/docs/products-domain.md +++ b/docs/products-domain.md @@ -60,6 +60,47 @@ Option names live both in `CanonicalProduct.option_names` (the values) and in `fields{}` (the file-presence marker the diff needs for the absent-vs-clear distinction). +## Dialects (INV-17, SLICE-8) + +A file is normalized to canonical names **at the codec boundary**, so every stage +downstream of `parse_csv` (validate → diff → apply) is dialect-agnostic — it only +ever sees canonical cells. Two dialects exist: `canonical` and `shopify`. + +`dialect_shopify.py` is the Shopify adapter and the source of truth for the +mapping (mirrored by `tests/test_products_dialect_shopify.py` and +`tests/fixtures/shopify-export.csv`): + +- **Detection** — `is_shopify_header()` returns `shopify` iff the header carries a + Shopify-only *signature* column (`Body (HTML)`, `Variant Grams`, `Cost per item`, + `Gift Card`, a `Google Shopping / …` column, …) **and no canonical-distinctive + column**. A canonical-distinctive name — one Shopify renames away (`Description`, + `Variant Cost`, `Variant Weight`, `Google Product Category`) or a canonical-only + column Shopify lacks (`Variant Volume`, `Variant Tax ID 1/2`, `Variant Position`) — + **vetoes** Shopify detection. Detection is by signature, not an exact header set, so + extra market/region columns don't defeat it; the veto biases it conservative, because + under-detection is safe (Shopify-named columns are warned as not-imported) while + over-detection corrupts (canonical `Type` / `Variant Weight Unit` would be dropped). + So a hybrid or ambiguous header falls through to `canonical`, which maps shared + columns identically and warns the rest — it never misparses (§7.4). +- **Mapping** — `map_shopify_header(header)` returns `(mapped, not_imported)`: + `mapped[i]` is the canonical name for column `i` (or `None` when it has no + canonical home), and `not_imported` is the warning list surfaced as the draft's + `unknown_columns`. The rule per column, in order: in `SHOPIFY_RENAME` → + canonical name; in `SHOPIFY_DROP` (`Type`, `Variant Weight Unit`) → not imported; + in `KNOWN_COLUMNS` → pass through; else → not imported (this last branch absorbs + the unbounded `… /Grey
,9.50,18.00,300,Drinkware,false\n" + ).encode("utf-8") + parsed = parse_csv(data) + assert parsed.dialect == "shopify" + cells = parsed.rows[0].cells + assert cells["Description"] == "Grey
" + assert cells["Variant Cost"] == "9.50" + assert cells["Variant Weight"] == "300" + assert cells["Variant Weight Unit"] == "g" # synthesized + # Type (free-text) and Gift Card warned, never mapped: + assert "Type" in parsed.unknown_columns + assert "Gift Card" in parsed.unknown_columns + assert "product_type" not in str(cells) # Type never reached canonical + + +def test_parse_canonical_unchanged(): + data = b"Handle,Title,Description,Variant Price\nmug,Moon Mug,Grey,18.00\n" + parsed = parse_csv(data) + assert parsed.dialect == "canonical" + assert parsed.rows[0].cells["Description"] == "Grey" + assert parsed.unknown_columns == [] +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd backend && python -m pytest tests/test_products_codec.py -k "shopify or canonical_unchanged" -v` +Expected: FAIL — `parsed.dialect == "canonical"` (detection still stubbed) / `KeyError: 'Description'` + +- [ ] **Step 3: Rewrite `detect_dialect` + `parse_csv`** in `codec.py` + +Replace the `detect_dialect` stub and the header/cell-building block. New `codec.py` (lines from the import down through `parse_csv`): + +```python +from .dialect_shopify import is_shopify_header, map_shopify_header +from .models import KNOWN_COLUMNS, MAX_DATA_ROWS, MAX_FILE_BYTES, ParsedFile, Row + +_REQUIRED_HEADER_COLUMNS = ("Handle", "Title") + + +def detect_dialect(header: list[str]) -> str: + """The INV-17 seam: recognize Shopify's header set, else canonical (§6.5.1).""" + return "shopify" if is_shopify_header(header) else "canonical" + + +def parse_csv(data: bytes) -> ParsedFile: + if len(data) > MAX_FILE_BYTES: + raise FileRejected("file_too_large", "This file is larger than 10 MB.") + try: + text = data.decode("utf-8-sig") + except UnicodeDecodeError: + raise FileRejected("not_csv", "This file isn't readable as CSV.") from None + reader = csv.reader(io.StringIO(text)) + try: + try: + raw_header = next(reader) + except StopIteration: + raise FileRejected("not_csv", "This file isn't readable as CSV.") from None + header = [h.strip() for h in raw_header] + dialect = detect_dialect(header) + + # INV-17: normalize the header to canonical names at the boundary. For Shopify, + # mapped[i] is the canonical name (or None when dropped); not_imported is the + # warning list. Canonical files map to themselves, unknowns warned as before. + if dialect == "shopify": + mapped, unknown = map_shopify_header(header) + else: + mapped = [c if c in KNOWN_COLUMNS else None for c in header] + unknown = [c for c in header if c and c not in KNOWN_COLUMNS] + + for col in _REQUIRED_HEADER_COLUMNS: + if col not in (mapped): + raise FileRejected( + "missing_required_column", + f"This file is missing the required column '{col}'.", + ) + + # First occurrence of a duplicated canonical column wins. + col_index: dict[str, int] = {} + for i, name in enumerate(mapped): + if name and name not in col_index: + col_index[name] = i + # De-dup the warning list, order-preserving. + seen: set[str] = set() + unknown = [c for c in unknown if not (c in seen or seen.add(c))] + + rows: list[Row] = [] + for raw in reader: + if not any(cell.strip() for cell in raw): + continue + if len(rows) >= MAX_DATA_ROWS: + raise FileRejected( + "too_many_rows", + f"This file has more than {MAX_DATA_ROWS:,} rows — split it and import in parts.", + ) + cells = { + c: (raw[col_index[c]].strip() if col_index[c] < len(raw) else "") + for c in col_index + } + # Shopify grams carry an implicit unit; canonical needs it explicit (§6.5.1). + if dialect == "shopify" and cells.get("Variant Weight"): + cells["Variant Weight Unit"] = "g" + rows.append(Row(line_number=reader.line_num, cells=cells)) + except csv.Error: + raise FileRejected("not_csv", "This file isn't readable as CSV.") from None + return ParsedFile(dialect=dialect, header=header, unknown_columns=unknown, rows=rows) +``` + +Note: the required-column check now reads `mapped` (canonical names) so a Shopify file whose `Title`/`Handle` are present passes, and a file genuinely missing them still fails honestly. `Handle` and `Title` are direct pass-through names in both dialects, so `mapped` contains them when present. + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd backend && python -m pytest tests/test_products_codec.py -v` +Expected: PASS (existing canonical tests + 2 new) + +- [ ] **Step 5: Run the whole products suite (no regressions)** + +Run: `cd backend && python -m pytest tests/ -q` +Expected: PASS — validate/diff/invariants unchanged (they consume canonical cells; INV-17 holds). + +- [ ] **Step 6: Commit** + +```bash +git add backend/app/domains/products/codec.py backend/tests/test_products_codec.py +git commit -m "feat(products): codec detects + maps Shopify dialect at the boundary (INV-17, SLICE-8)" +``` + +--- + +### Task 3: Exhaustive-mapping fixture test (the §6.5.1 pin) + +**Files:** +- Create: `backend/tests/fixtures/shopify-export.csv` +- Test: `backend/tests/test_products_dialect_shopify.py` (append) + +- [ ] **Step 1: Create a realistic full-width Shopify export fixture** + +`backend/tests/fixtures/shopify-export.csv` — one product, two variants, one image-only row, exercising every mapped + several not-imported columns: + +```csv +Handle,Title,Body (HTML),Vendor,Product Category,Type,Tags,Published,Status,Option1 Name,Option1 Value,Variant SKU,Variant Grams,Variant Inventory Tracker,Variant Inventory Qty,Variant Inventory Policy,Variant Fulfillment Service,Variant Price,Variant Compare At Price,Variant Requires Shipping,Variant Taxable,Variant Barcode,Image Src,Image Position,Image Alt Text,Gift Card,SEO Title,SEO Description,Google Shopping / MPN,Variant Image,Variant Weight Unit,Cost per item,Status +star-tee,Star Tee,Soft cotton tee.
,Wiggle Goods,Apparel & Accessories > Clothing,Shirts,"apparel, tees",TRUE,active,Size,S,WG-TEE-S,180,shopify,12,deny,manual,24.00,30.00,TRUE,TRUE,0001,https://img.example.com/star-tee.jpg,1,Star Tee,FALSE,Star Tee | Wiggle,Soft tee,MPN-1,https://img.example.com/star-s.jpg,g,11.00,active +star-tee,,,,,,,,,,M,WG-TEE-M,180,shopify,18,deny,manual,24.00,30.00,TRUE,TRUE,0002,,,,FALSE,,,,,g,11.00, +star-tee,,,,,,,,,,,,,,,,,,,,,,https://img.example.com/star-back.jpg,2,Star Tee back,,,,,,,, +``` + +- [ ] **Step 2: Write the failing test** + +```python +import csv as _csv +import io +from pathlib import Path + +from app.domains.products.codec import parse_csv + +_FIXTURE = Path(__file__).parent / "fixtures" / "shopify-export.csv" + + +def test_shopify_fixture_maps_exhaustively(): + parsed = parse_csv(_FIXTURE.read_bytes()) + assert parsed.dialect == "shopify" + first = parsed.rows[0].cells + # renamed + assert first["Description"] == "Soft cotton tee.
" + assert first["Google Product Category"] == "Apparel & Accessories > Clothing" + assert first["Variant Cost"] == "11.00" + assert first["Variant Weight"] == "180" + assert first["Variant Weight Unit"] == "g" + # direct + assert first["Variant SKU"] == "WG-TEE-S" + assert first["Image Src"] == "https://img.example.com/star-tee.jpg" + # not imported — warned, none leaked into canonical cells + for col in ("Type", "Variant Compare At Price", "Gift Card", "SEO Title", + "Google Shopping / MPN", "Variant Weight Unit", "Variant Inventory Policy", + "Variant Fulfillment Service", "Variant Requires Shipping", "Variant Taxable"): + assert col in parsed.unknown_columns, col + assert "Variant Compare At Price" not in first +``` + +- [ ] **Step 3: Run — expect PASS** (codec from Task 2 already maps correctly) + +Run: `cd backend && python -m pytest tests/test_products_dialect_shopify.py::test_shopify_fixture_maps_exhaustively -v` +Expected: PASS. If a column assertion fails, fix the mapping table in `dialect_shopify.py` to match this fixture — the fixture and table must agree. + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/fixtures/shopify-export.csv backend/tests/test_products_dialect_shopify.py +git commit -m "test(products): exhaustive Shopify->canonical mapping fixture (SD-0002 §6.5.1, SLICE-8)" +``` + +--- + +### Task 4: INV-10 over a partial Shopify import (never deletes) + +**Files:** +- Test: `backend/tests/test_products_invariants.py` (append) + +- [ ] **Step 1: Inspect the existing INV-10 test** to reuse its catalog/diff helpers + +Run: `cd backend && grep -n "def test_inv10\|compute_diff\|build_products\|catalog" tests/test_products_invariants.py | head` +Expected: shows the helper pattern (catalog fixture → parse → build_products → compute_diff → assert no deletes in plan). + +- [ ] **Step 2: Write the failing test** (mirror the existing INV-10 test, swapping in a Shopify file that omits an existing product) + +```python +def test_inv10_shopify_partial_never_deletes(): + # An existing catalog of two products; a Shopify file mentioning only one. + catalog = _catalog_with(["star-tee", "moon-mug"]) # existing helper + data = ( + "Handle,Title,Body (HTML),Variant Price,Variant Grams\n" + "star-tee,Star Tee,x
,24.00,180\n" + ).encode("utf-8") + parsed = parse_csv(data) + assert parsed.dialect == "shopify" + products = build_products(parsed) + result = compute_diff(catalog, products) + # INV-10: nothing in the plan deletes; moon-mug (unmentioned) is untouched. + assert all(op.kind != "delete" for op in result.plan) + assert "moon-mug" not in {p.handle for p in products} +``` + +Adapt `_catalog_with` / `op.kind` / `result.plan` to the actual symbols found in Step 1. + +- [ ] **Step 3: Run to verify it passes** + +Run: `cd backend && python -m pytest tests/test_products_invariants.py -k inv10_shopify -v` +Expected: PASS (INV-10 is dialect-agnostic; this proves it over Shopify). + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_products_invariants.py +git commit -m "test(products): INV-10 holds over a partial Shopify import (SLICE-8)" +``` + +--- + +### Task 5: Frontend — Shopify label + format help + +**Files:** +- Modify: `frontend/src/productsApi.ts:52-54` +- Modify: `frontend/src/screens/products/ImportUpload.tsx:53` +- Modify: `frontend/src/screens/products/ProductsPage.tsx:114` +- Test: `frontend/src/productsApi.test.ts` + +- [ ] **Step 1: Write the failing test** (create or append `frontend/src/productsApi.test.ts`) + +```ts +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", () => + expect(dialectLabel("shopify")).toBe("Shopify product CSV — mapped")); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd frontend && npm test -- productsApi` +Expected: FAIL — receives `"shopify"`, expected `"Shopify product CSV — mapped"`. + +- [ ] **Step 3: Update `dialectLabel`** + +```ts +// frontend/src/productsApi.ts +export function dialectLabel(d: string): string { + if (d === "canonical") return "Canonical format"; + if (d === "shopify") return "Shopify product CSV — mapped"; + return d; +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd frontend && npm test -- productsApi` +Expected: PASS + +- [ ] **Step 5: Update the upload-screen format help** — `ImportUpload.tsx:53` + +Replace the line `Works with the canonical format.{" "}` and the following sample link block so it reads (PUC-11, §5.4 wireframe text): + +```tsx + Works with the canonical format or a Shopify product CSV.{" "} + + Download a sample + {" "} + ·{" "} + + Column reference + +``` + +- [ ] **Step 6: Add the column-reference link beside the sample on the Products page** — `ProductsPage.tsx:114` + +After the existing `` element, add: + +```tsx + {" · "} + + Column reference + +``` + +- [ ] **Step 7: Build + typecheck + test** + +Run: `cd frontend && npm run build && npm test` +Expected: PASS (no TS errors, vitest green). + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/productsApi.ts frontend/src/productsApi.test.ts frontend/src/screens/products/ImportUpload.tsx frontend/src/screens/products/ProductsPage.tsx +git commit -m "feat(products-ui): Shopify 'mapped' label + format help links to column reference (PUC-6/PUC-11, SLICE-8)" +``` + +--- + +### Task 6: DOC-2 column reference + route; finalize DOC-3 + +**Files:** +- Create: `backend/app/domains/products/columns.md` +- Modify: `backend/app/main.py` (after the `sample.csv` route, ~line 451) +- Modify: `backend/app/domains/products/__init__.py` (export `COLUMNS_MD_PATH` next to `SAMPLE_CSV_PATH`) +- Test: the existing products API test module (find it in Step 4) + +- [ ] **Step 1: Write the DOC-2 column reference** — `backend/app/domains/products/columns.md` + +A complete merchant-facing reference: a short intro (canonical = Shopify-flavored superset; auto-detection; blank-vs-absent semantics), then a table of **every canonical column** (name · level · required · accepted values), then a **Shopify dialect notes** section reproducing the Task-1 mapping table (renamed, direct, not-imported). Source the canonical column rows from spec §6.5.1 (the table at lines 852-873) and the dialect notes from §6.5.1 (lines 880-889). Keep it plain Markdown — no app-specific build step. + +- [ ] **Step 2: Export the path** — `backend/app/domains/products/__init__.py` + +Add beside the existing `SAMPLE_CSV_PATH`: + +```python +COLUMNS_MD_PATH = _HERE / "columns.md" +``` + +(Use the same `_HERE` / `Path(__file__).parent` idiom already present for `SAMPLE_CSV_PATH`.) + +- [ ] **Step 3: Add the route** — `backend/app/main.py`, immediately after `products_sample_csv` + +```python + @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", + ) +``` + +- [ ] **Step 4: Write the failing route test** — find the API test module first + +Run: `cd backend && ls tests/ | grep -i "api\|main"` +Append to the module that tests the other unauthenticated doc routes: + +```python +def test_columns_md_served(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 # dialect notes present + assert "Handle" in body # canonical columns present +``` + +- [ ] **Step 5: Run to verify it passes** + +Run: `cd backend && python -m pytest tests/ -k "columns_md or sample" -v` +Expected: PASS + +- [ ] **Step 6: Verify DOC-3 sample.csv is final** — it already carries a variant product (`star-tee`, S/M/L) and image rows. Confirm it parses clean as canonical: + +Run: `cd backend && python -c "from app.domains.products.codec import parse_csv; from app.domains.products import SAMPLE_CSV_PATH; p=parse_csv(SAMPLE_CSV_PATH.read_bytes()); print(p.dialect, len(p.rows), p.unknown_columns)"` +Expected: `canonical 5 []` (no unknown columns; 5 data rows). If `unknown_columns` is non-empty, fix the sample header to canonical names. + +- [ ] **Step 7: Commit** + +```bash +git add backend/app/domains/products/columns.md backend/app/domains/products/__init__.py backend/app/main.py backend/tests/ +git commit -m "docs(products): DOC-2 column reference served at /api/products/columns.md (SLICE-8)" +``` + +--- + +### Task 7: DOC-4 dialect-adapter dev notes + +**Files:** +- Modify: `docs/products-domain.md` + +- [ ] **Step 1: Add a "Dialects (INV-17)" subsection** documenting: detection by signature columns; the `dialect_shopify` adapter; the rename/drop/passthrough algorithm; the grams→weight transform; that all downstream stages are dialect-agnostic. Point to `dialect_shopify.py` and the fixture as the source of truth (documentation-leads-automation, §4.1). + +- [ ] **Step 2: Commit** + +```bash +git add docs/products-domain.md +git commit -m "docs(products): DOC-4 dialect-adapter notes (SLICE-8)" +``` + +--- + +### Task 8: E2E — `e2e_import_shopify_dialect` + +**Files:** +- Create: `e2e/fixtures/shopify-export.csv` (copy the Task-3 fixture) +- Create: `e2e/tests/import-shopify-dialect.spec.ts` + +- [ ] **Step 1: Add the fixture** — same content as `backend/tests/fixtures/shopify-export.csv`. + +- [ ] **Step 2: Read an existing import E2E spec** to reuse the sign-up + upload helpers + +Run: `sed -n '1,60p' e2e/tests/import-preview-confirm.spec.ts` +Expected: shows the page-object / sign-up helper and the upload→preview→confirm flow to mirror. + +- [ ] **Step 3: Write `e2e/tests/import-shopify-dialect.spec.ts`** (mirroring that helper) + +```ts +import { test, expect } from "@playwright/test"; +import { signUpAndOpenImport } from "./helpers"; // use the actual helper from Step 2 + +test("e2e_import_shopify_dialect: a Shopify export imports directly (PUC-6)", async ({ page }) => { + await signUpAndOpenImport(page); + await page.getByLabel(/choose|upload|file/i).setInputFiles("fixtures/shopify-export.csv"); + // Preview shows the mapped banner + not-imported warning + await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible(); + await expect(page.getByText(/not imported/i)).toBeVisible(); + await expect(page.getByText("Type")).toBeVisible(); // a warned column + // Confirm and land on the run detail + await page.getByRole("button", { name: /^Import \d/ }).click(); + await expect(page).toHaveURL(/\/products\/imports\/runs\/\d+/); + await expect(page.getByText("Shopify product CSV — mapped")).toBeVisible(); +}); +``` + +Adapt selectors to the real upload control + helper names found in Step 2. + +- [ ] **Step 4: Run the E2E suite** + +Run: `./scripts/e2e.sh` +Expected: all specs PASS including `import-shopify-dialect` (7 prior + 1 new = 8). + +- [ ] **Step 5: Commit** + +```bash +git add e2e/fixtures/shopify-export.csv e2e/tests/import-shopify-dialect.spec.ts +git commit -m "test(e2e): e2e_import_shopify_dialect — Shopify export imports directly (PUC-6, SLICE-8)" +``` + +--- + +### Task 9: Traceability audit, version bump, full green + +**Files:** +- Modify: version source (`backend/app/__init__.py` or `app.json`) + any `CHANGELOG` +- Read-only: spec §12 traceability matrix + +- [ ] **Step 1: Audit §12 traceability** — confirm PUC-6, PUC-11, BUC-1 (Shopify), DOC-2, DOC-3 now have shipping code + tests. Note any gap in the transcript `## Deferred decisions`. (Spec audit is read-only; do not edit the content repo here — finalize submits the plan.) + +- [ ] **Step 2: Bump the version to v0.8.0** + +Run: `cd backend && grep -rn "0.7.0" app/__init__.py app.json 2>/dev/null` +Update the version string to `0.8.0` wherever the prior slice set it (mirror the SLICE-7 v0.7.0 bump). + +- [ ] **Step 3: Full local gate** + +Run: `./scripts/check.sh && ./scripts/e2e.sh` (or the repo's canonical localhost gate) +Expected: backend pytest, frontend build+vitest, import-linter, and Playwright all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "chore(products): SLICE-8 complete — bump v0.8.0; §12 traceability audited (SD-0002)" +``` + +--- + +## Post-plan (session flow, not subagent tasks) + +After all tasks are green on the branch: +1. **PR → merge to `main`** (branch→PR→merge; §5.4). +2. **PPE deploy** via flotilla + E2E green on `https://ecomm-ppe.wiggleverse.org`; stamp the `release/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/tests/import-shopify-dialect.spec.ts b/e2e/tests/import-shopify-dialect.spec.ts new file mode 100644 index 0000000..63a7b17 --- /dev/null +++ b/e2e/tests/import-shopify-dialect.spec.ts @@ -0,0 +1,35 @@ +// 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/frontend/package-lock.json b/frontend/package-lock.json index cb4c6d9..5a8e519 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "wiggleverse-ecomm-frontend", - "version": "0.7.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wiggleverse-ecomm-frontend", - "version": "0.7.0", + "version": "0.8.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/package.json b/frontend/package.json index 25ee4f9..c207e7a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "wiggleverse-ecomm-frontend", "private": true, - "version": "0.7.0", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/productsApi.dialect.test.ts b/frontend/src/productsApi.dialect.test.ts new file mode 100644 index 0000000..dd53acc --- /dev/null +++ b/frontend/src/productsApi.dialect.test.ts @@ -0,0 +1,10 @@ +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.ts b/frontend/src/productsApi.ts index 03ca4c8..94c2dbd 100644 --- a/frontend/src/productsApi.ts +++ b/frontend/src/productsApi.ts @@ -50,7 +50,9 @@ export type Result- Works with the canonical format.{" "} + Works with the canonical format or a Shopify product CSV.{" "} Download sample CSV + {" "} + ·{" "} + + Column reference
diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx index 7aff087..29f3180 100644 --- a/frontend/src/screens/products/ProductsPage.tsx +++ b/frontend/src/screens/products/ProductsPage.tsx @@ -113,6 +113,10 @@ export default function ProductsPage() {Download sample CSV + {" "} + ·{" "} + + Column reference