feat(products): confirm/apply in one transaction, runs, summary — INV-10/11/14 + TEL-2/6

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:02:27 -07:00
parent fcbf1393f5
commit 0c7865e9e1
5 changed files with 439 additions and 39 deletions
+11 -1
View File
@@ -16,11 +16,21 @@ from .errors import (
RunNotFound, RunNotFound,
) )
from .models import MAX_DATA_ROWS, MAX_FILE_BYTES from .models import MAX_DATA_ROWS, MAX_FILE_BYTES
from .service import discard_draft, get_draft, get_draft_records, import_validate from .service import (
confirm_draft,
discard_draft,
get_draft,
get_draft_records,
get_run,
import_validate,
list_runs,
summary,
)
__all__ = [ __all__ = [
"ProductsError", "FileRejected", "DraftNotFound", "DraftExpired", "ProductsError", "FileRejected", "DraftNotFound", "DraftExpired",
"PreviewStale", "NothingToApply", "RunNotFound", "PreviewStale", "NothingToApply", "RunNotFound",
"MAX_DATA_ROWS", "MAX_FILE_BYTES", "MAX_DATA_ROWS", "MAX_FILE_BYTES",
"import_validate", "get_draft", "get_draft_records", "discard_draft", "import_validate", "get_draft", "get_draft_records", "discard_draft",
"confirm_draft", "list_runs", "get_run", "summary",
] ]
+127 -37
View File
@@ -1,9 +1,12 @@
"""Diff engine — catalog × canonical products → preview records (SD-0002 §6.5.2). """Diff engine — catalog × canonical products → apply plan + preview records (SD-0002 §6.5.2).
Classifies each canonical product against the storefront's current catalog as Classifies each canonical product against the storefront's current catalog as
add / update / unchanged / error, producing JSON-ready preview records (stored add / update / unchanged / error. One walk produces two views of the same
as draft JSONB, served verbatim to the SPA) plus a summary and a deterministic computation: a typed apply *plan* carrying resolved native values (Decimal etc.)
fingerprint that detects catalog drift between preview and confirm (INV-11). for the confirm transaction, and JSON-ready preview records derived from that
walk (stored as draft JSONB, served verbatim to the SPA) — so what confirm
applies is exactly what preview showed (INV-11). A summary and a deterministic
fingerprint over the records detect catalog drift between preview and confirm.
Deliberately DB-free: the catalog snapshot dataclasses are defined here and the Deliberately DB-free: the catalog snapshot dataclasses are defined here and the
repo layer builds them. Only fields present in the file's canonical fields{} repo layer builds them. Only fields present in the file's canonical fields{}
@@ -16,7 +19,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
from dataclasses import dataclass from dataclasses import dataclass, field
from decimal import Decimal from decimal import Decimal
from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant from .models import CLEAR_DEFAULTS, CanonicalProduct, CanonicalVariant
@@ -54,11 +57,49 @@ class CatalogProduct:
images: list[CatalogImage] images: list[CatalogImage]
# ---------------------------------------------------------------------------
# Apply plan — the typed twin of the preview records. confirm_draft executes
# these; the values are resolved natives (Decimal, bool, list), never the
# json-safe strings the records carry for display.
# ---------------------------------------------------------------------------
@dataclass
class VariantPlan:
kind: str # "add" | "update"
canonical: CanonicalVariant
catalog_id: int | None # None for add
# field -> resolved after-value (update only); adds resolve from canonical.fields
changes: dict[str, object] = field(default_factory=dict)
@dataclass
class ImagePlan:
kind: str # "add" | "update"
source_url: str
position: int
alt_text: str | None
image_id: int | None # None for add
changes: dict[str, object] = field(default_factory=dict) # subset of {"position","alt_text"}
@dataclass
class ProductPlan:
kind: str # "add" | "update" | "unchanged" | "error"
canonical: CanonicalProduct
catalog: CatalogProduct | None
# field -> resolved after-value (update only; may include title/option*_name)
product_changes: dict[str, object] = field(default_factory=dict)
variant_plans: list[VariantPlan] = field(default_factory=list)
image_plans: list[ImagePlan] = field(default_factory=list)
@dataclass(frozen=True) @dataclass(frozen=True)
class DiffResult: class DiffResult:
records: list[dict] records: list[dict]
summary: dict summary: dict
fingerprint: str fingerprint: str
plan: list[ProductPlan]
# Option-name presence markers in fields{} (see validate.py); the values are # Option-name presence markers in fields{} (see validate.py); the values are
@@ -69,16 +110,20 @@ _SUMMARY_KEY = {"add": "adds", "update": "updates", "unchanged": "unchanged", "e
def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult: def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalProduct]) -> DiffResult:
records: list[dict] = [] records: list[dict] = []
plan: list[ProductPlan] = []
summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0} summary = {"adds": 0, "updates": 0, "unchanged": 0, "errors": 0}
for product in products: for product in products:
current = catalog.get(product.handle) current = catalog.get(product.handle)
if product.errors: if product.errors:
kind, detail = "error", {"errors": [e.as_json() for e in product.errors]} product_plan = ProductPlan(kind="error", canonical=product, catalog=current)
detail: dict = {"errors": [e.as_json() for e in product.errors]}
elif current is None: elif current is None:
kind, detail = "add", _add_detail(product) product_plan = _add_plan(product)
detail = _add_detail(product_plan)
else: else:
detail = _update_detail(product, current) product_plan, detail = _update_plan(product, current)
kind = "update" if detail else "unchanged" kind = product_plan.kind
plan.append(product_plan)
records.append( records.append(
{ {
"handle": product.handle, "handle": product.handle,
@@ -92,86 +137,125 @@ def compute_diff(catalog: dict[str, CatalogProduct], products: list[CanonicalPro
fingerprint = hashlib.sha256( fingerprint = hashlib.sha256(
json.dumps(records, sort_keys=True, separators=(",", ":")).encode() json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
).hexdigest() ).hexdigest()
return DiffResult(records=records, summary=summary, fingerprint=fingerprint) return DiffResult(records=records, summary=summary, fingerprint=fingerprint, plan=plan)
def _add_detail(product: CanonicalProduct) -> dict: def _add_plan(product: CanonicalProduct) -> ProductPlan:
return ProductPlan(
kind="add",
canonical=product,
catalog=None,
variant_plans=[VariantPlan(kind="add", canonical=v, catalog_id=None) for v in product.variants],
image_plans=[
ImagePlan(kind="add", source_url=i.source_url, position=i.position,
alt_text=i.alt_text, image_id=None)
for i in product.images
],
)
def _add_detail(plan: ProductPlan) -> dict:
# On add, absent fields fall back to their defaults for display where one # On add, absent fields fall back to their defaults for display where one
# exists — the detail shows what will actually be set. # exists — the detail shows what will actually be set.
set_fields = _resolved_product_fields(product) set_fields = resolved_product_fields(plan.canonical)
for field_name, default in CLEAR_DEFAULTS.items(): for field_name, default in CLEAR_DEFAULTS.items():
set_fields.setdefault(field_name, default) set_fields.setdefault(field_name, default)
return { return {
"set": {f: _json_safe(v) for f, v in set_fields.items()}, "set": {f: _json_safe(v) for f, v in set_fields.items()},
"option_names": list(product.option_names), "option_names": list(plan.canonical.option_names),
"variants": [ "variants": [
{"options": list(v.options), "set": _resolved_variant_fields(v)} {"options": list(vp.canonical.options), "set": _resolved_variant_fields(vp.canonical)}
for v in product.variants for vp in plan.variant_plans
], ],
"images": [ "images": [
{"src": i.source_url, "position": i.position, "alt_text": i.alt_text} {"src": ip.source_url, "position": ip.position, "alt_text": ip.alt_text}
for i in product.images for ip in plan.image_plans
], ],
} }
def _update_detail(product: CanonicalProduct, current: CatalogProduct) -> dict: def _update_plan(product: CanonicalProduct, current: CatalogProduct) -> tuple[ProductPlan, dict]:
"""One walk, two outputs: the resolved-value plan entries and the json-safe
record detail entries are appended side by side, so they can never diverge."""
product_changes: dict[str, object] = {}
changes: list[dict] = [] changes: list[dict] = []
# Title is always file-present (required header column); "" means the block # Title is always file-present (required header column); "" means the block
# already carries an error and never reaches here. # already carries an error and never reaches here.
if product.title and product.title != current.title: if product.title and product.title != current.title:
product_changes["title"] = product.title
changes.append(_change("title", current.title, product.title)) changes.append(_change("title", current.title, product.title))
for field_name, resolved in _resolved_product_fields(product).items(): for field_name, resolved in resolved_product_fields(product).items():
before = current.fields.get(field_name) before = current.fields.get(field_name)
if resolved != before: if resolved != before:
product_changes[field_name] = resolved
changes.append(_change(field_name, before, resolved)) changes.append(_change(field_name, before, resolved))
for slot in (1, 2, 3): for slot in (1, 2, 3):
if f"option{slot}_name" not in product.fields: if f"option{slot}_name" not in product.fields:
continue continue
before, after = current.option_names[slot - 1], product.option_names[slot - 1] before, after = current.option_names[slot - 1], product.option_names[slot - 1]
if after != before: if after != before:
product_changes[f"option{slot}_name"] = after
changes.append(_change(f"option{slot}_name", before, after)) changes.append(_change(f"option{slot}_name", before, after))
variant_plans: list[VariantPlan] = []
variant_entries: list[dict] = [] variant_entries: list[dict] = []
by_options = {v.options: v for v in current.variants} by_options = {v.options: v for v in current.variants}
for variant in product.variants: for variant in product.variants:
match = by_options.get(variant.options) match = by_options.get(variant.options)
if match is None: if match is None:
variant_plans.append(VariantPlan(kind="add", canonical=variant, catalog_id=None))
variant_entries.append( variant_entries.append(
{"options": list(variant.options), "kind": "add", "set": _resolved_variant_fields(variant)} {"options": list(variant.options), "kind": "add", "set": _resolved_variant_fields(variant)}
) )
continue continue
variant_changes = [] variant_changes: dict[str, object] = {}
for f, resolved in _resolved_fields(variant.fields).items(): variant_change_entries: list[dict] = []
for f, resolved in resolved_fields(variant.fields).items():
# position lives on the catalog variant as an attribute, not in # position lives on the catalog variant as an attribute, not in
# fields{} — compare it explicitly (as images do for theirs). # fields{} — compare it explicitly (as images do for theirs).
before = match.position if f == "position" else match.fields.get(f) before = match.position if f == "position" else match.fields.get(f)
if resolved != before: if resolved != before:
variant_changes.append(_change(f, before, resolved)) variant_changes[f] = resolved
variant_change_entries.append(_change(f, before, resolved))
if variant_changes: if variant_changes:
variant_plans.append(
VariantPlan(kind="update", canonical=variant, catalog_id=match.id, changes=variant_changes)
)
variant_entries.append( variant_entries.append(
{"options": list(variant.options), "kind": "update", "changes": variant_changes} {"options": list(variant.options), "kind": "update", "changes": variant_change_entries}
) )
image_plans: list[ImagePlan] = []
image_entries: list[dict] = [] image_entries: list[dict] = []
by_src = {i.source_url: i for i in current.images} by_src = {i.source_url: i for i in current.images}
for image in product.images: for image in product.images:
match = by_src.get(image.source_url) match = by_src.get(image.source_url)
if match is None: if match is None:
image_plans.append(
ImagePlan(kind="add", source_url=image.source_url, position=image.position,
alt_text=image.alt_text, image_id=None)
)
image_entries.append( image_entries.append(
{"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text} {"src": image.source_url, "kind": "add", "position": image.position, "alt_text": image.alt_text}
) )
continue continue
image_changes = [ image_changes: dict[str, object] = {}
_change(f, before, after) image_change_entries: list[dict] = []
for f, before, after in ( for f, before, after in (
("position", match.position, image.position), ("position", match.position, image.position),
("alt_text", match.alt_text, image.alt_text), ("alt_text", match.alt_text, image.alt_text),
) ):
if after != before if after != before:
] image_changes[f] = after
image_change_entries.append(_change(f, before, after))
if image_changes: if image_changes:
image_entries.append({"src": image.source_url, "kind": "update", "changes": image_changes}) image_plans.append(
ImagePlan(kind="update", source_url=image.source_url, position=image.position,
alt_text=image.alt_text, image_id=match.id, changes=image_changes)
)
image_entries.append(
{"src": image.source_url, "kind": "update", "changes": image_change_entries}
)
detail: dict = {} detail: dict = {}
if changes: if changes:
@@ -180,22 +264,28 @@ def _update_detail(product: CanonicalProduct, current: CatalogProduct) -> dict:
detail["variants"] = variant_entries detail["variants"] = variant_entries
if image_entries: if image_entries:
detail["images"] = image_entries detail["images"] = image_entries
return detail kind = "update" if detail else "unchanged"
return (
ProductPlan(kind=kind, canonical=product, catalog=current, product_changes=product_changes,
variant_plans=variant_plans, image_plans=image_plans),
detail,
)
def _resolved_fields(fields: dict[str, object]) -> dict[str, object]: def resolved_fields(fields: dict[str, object]) -> dict[str, object]:
"""File-present fields with None (an explicit clear) resolved to the default.""" """File-present fields with None (an explicit clear) resolved to the default."""
return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()} return {f: (v if v is not None else CLEAR_DEFAULTS.get(f)) for f, v in fields.items()}
def _resolved_product_fields(product: CanonicalProduct) -> dict[str, object]: def resolved_product_fields(product: CanonicalProduct) -> dict[str, object]:
"""Resolved product-level fields, minus the option-name presence markers."""
return { return {
f: v for f, v in _resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS f: v for f, v in resolved_fields(product.fields).items() if f not in _OPTION_NAME_FIELDS
} }
def _resolved_variant_fields(variant: CanonicalVariant) -> dict[str, object]: def _resolved_variant_fields(variant: CanonicalVariant) -> dict[str, object]:
return {f: _json_safe(v) for f, v in _resolved_fields(variant.fields).items()} return {f: _json_safe(v) for f, v in resolved_fields(variant.fields).items()}
def _change(field_name: str, before: object, after: object) -> dict: def _change(field_name: str, before: object, after: object) -> dict:
+136 -1
View File
@@ -14,7 +14,7 @@ import psycopg
from app.platform import telemetry from app.platform import telemetry
from . import codec, diff, repo, validate from . import codec, diff, repo, validate
from .errors import DraftExpired, DraftNotFound from .errors import DraftExpired, DraftNotFound, NothingToApply, PreviewStale, RunNotFound
def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int, def import_validate(conn: psycopg.Connection, storefront_id: int, account_id: int,
@@ -86,3 +86,138 @@ def discard_draft(conn: psycopg.Connection, storefront_id: int, draft_id: int) -
"""Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a).""" """Delete the draft, no trace kept; idempotent — an absent draft is fine (PUC-3a)."""
repo.delete_draft(conn, storefront_id, draft_id) repo.delete_draft(conn, storefront_id, draft_id)
conn.commit() 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"]:
raise PreviewStale()
summary_counts = diff_result.summary
if summary_counts["adds"] + summary_counts["updates"] == 0:
raise NothingToApply()
error_rows = [
error.as_json()
for plan in diff_result.plan if plan.kind == "error"
for error in plan.canonical.errors
]
try:
run_id = repo.insert_run(
conn, storefront_id, account_id, row["file_name"], row["dialect"],
added=summary_counts["adds"], updated=summary_counts["updates"],
errored=len(error_rows), status="complete",
)
for plan in diff_result.plan:
_apply_product_plan(conn, storefront_id, plan, run_id)
repo.insert_run_errors(conn, run_id, error_rows)
repo.delete_draft(conn, storefront_id, draft_id)
conn.commit()
except Exception as exc:
conn.rollback()
telemetry.emit(
"import_apply_failed",
draft_id=draft_id,
storefront_id=storefront_id,
error_class=type(exc).__name__,
)
raise
telemetry.emit(
"import_run_completed",
run_id=run_id,
storefront_id=storefront_id,
added=summary_counts["adds"],
updated=summary_counts["updates"],
errored=len(error_rows),
duration_ms=int((time.monotonic() - started) * 1000),
)
return run_id
def _apply_product_plan(conn: psycopg.Connection, storefront_id: int,
plan: diff.ProductPlan, run_id: int) -> None:
"""Execute one product's plan inside the confirm transaction (no commits here)."""
if plan.kind == "add":
# Title is a canonical attribute, not a fields{} entry — non-error
# products always carry one (validate guarantees it).
product_fields = {"title": plan.canonical.title}
product_fields.update(diff.resolved_product_fields(plan.canonical))
product_id = repo.insert_product(
conn, storefront_id, plan.canonical.handle, product_fields, plan.canonical.option_names
)
image_ids: dict[str, int] = {}
elif plan.kind == "update":
product_id = plan.catalog.id
repo.update_product(conn, product_id, plan.product_changes)
image_ids = {image.source_url: image.id for image in plan.catalog.images}
else:
return
# Images first, so variants' variant_image URLs resolve to ids: validate puts
# every variant_image URL into canonical.images, so each URL is in either the
# catalog map (existing image) or the adds below.
for image_plan in plan.image_plans:
if image_plan.kind == "add":
image_ids[image_plan.source_url] = repo.get_or_create_image(
conn, product_id, image_plan.source_url, image_plan.position,
image_plan.alt_text, run_id,
)
else:
repo.update_image(conn, image_plan.image_id, image_plan.changes)
# File order within the product, for variant adds without an explicit position.
file_positions = {id(v): i for i, v in enumerate(plan.canonical.variants, start=1)}
for variant_plan in plan.variant_plans:
if variant_plan.kind == "add":
fields = diff.resolved_fields(variant_plan.canonical.fields)
position = fields.get("position") or file_positions[id(variant_plan.canonical)]
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 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),
}
+79
View File
@@ -0,0 +1,79 @@
"""SD-0002 invariants: INV-10 (never deletes), INV-14 (two-storefront zero bleed),
apply transactionality (§6.8), TEL-6."""
import json
import logging
import psycopg
import pytest
from app.domains import products
from app.domains.products import repo, service
from app.platform import db
CSV_A = b"Handle,Title,Variant Price\nmug,Mug,10.00\ntee,Tee,20.00\n"
CSV_PARTIAL = b"Handle,Title,Variant Price\nmug,Mug,12.00\n"
@pytest.fixture()
def migrated_conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as conn:
db.migrate(conn)
yield conn
def _merchant(conn, email="m@example.com", shop="Shop"):
acct = conn.execute("INSERT INTO account (email) VALUES (%s) RETURNING id", (email,)).fetchone()[0]
sf = conn.execute("INSERT INTO storefront (name) VALUES (%s) RETURNING id", (shop,)).fetchone()[0]
conn.execute("INSERT INTO storefront_membership (account_id, storefront_id) VALUES (%s,%s)", (acct, sf))
conn.commit()
return acct, sf
def _import(conn, acct, sf, data):
d = products.import_validate(conn, sf, acct, "f.csv", data)
return products.confirm_draft(conn, sf, acct, d["id"])
def test_inv10_partial_file_never_deletes(migrated_conn):
acct, sf = _merchant(migrated_conn)
_import(migrated_conn, acct, sf, CSV_A)
before = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
_import(migrated_conn, acct, sf, CSV_PARTIAL)
after = migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0]
assert after >= before == 2
def test_inv14_two_storefronts_zero_bleed(migrated_conn):
acct1, sf1 = _merchant(migrated_conn)
acct2, sf2 = _merchant(migrated_conn, "n@example.com", "Other")
_import(migrated_conn, acct1, sf1, CSV_A)
assert products.summary(migrated_conn, sf2)["product_count"] == 0
assert products.list_runs(migrated_conn, sf2) == []
_import(migrated_conn, acct2, sf2, CSV_A)
assert products.summary(migrated_conn, sf2)["product_count"] == 2
run1 = products.list_runs(migrated_conn, sf1)[0]
with pytest.raises(products.RunNotFound):
products.get_run(migrated_conn, sf2, run1["id"])
def test_apply_failure_rolls_back_whole_transaction_tel6(migrated_conn, monkeypatch, caplog):
acct, sf = _merchant(migrated_conn)
d = products.import_validate(migrated_conn, sf, acct, "f.csv", CSV_A)
def boom(*a, **k):
raise RuntimeError("mid-apply crash")
monkeypatch.setattr(service.repo, "insert_run_errors", boom)
lg = logging.getLogger("ecomm")
prior = lg.propagate
lg.propagate = True
try:
with caplog.at_level(logging.INFO, logger="ecomm.telemetry"):
with pytest.raises(RuntimeError):
products.confirm_draft(migrated_conn, sf, acct, d["id"])
finally:
lg.propagate = prior
assert migrated_conn.execute("SELECT count(*) FROM product").fetchone()[0] == 0
assert migrated_conn.execute("SELECT count(*) FROM import_run").fetchone()[0] == 0
assert migrated_conn.execute("SELECT count(*) FROM import_draft").fetchone()[0] == 1
events = [json.loads(r.message) for r in caplog.records if r.name == "ecomm.telemetry"]
assert any(e["event"] == "import_apply_failed" and e["error_class"] == "RuntimeError" for e in events)
+86
View File
@@ -115,3 +115,89 @@ def test_tel1_emitted(migrated_conn, merchant, caplog, telemetry_propagation):
and "duration_ms" in e and e["unknown_columns_count"] == 0 and "duration_ms" in e and e["unknown_columns_count"] == 0
for e in events 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_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)