"""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"