chore(reset)!: strip storefront → network-service seed (v0.9.0)
ci / check (push) Has been cancelled
ci / check (push) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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"}}
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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,<p>x</p>,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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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("<!doctype html><title>ecomm spa</title>")
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user