Derivation and fixed values modules roll on their own rng stream (seeds through their alternations can resolve differently). DOM widgets tear down when their node is removed (effect scope, cleanups, shared onConnectionsChange wrapper, seed state in WeakMaps, capped topbar rAF loop). /import/commit validates module and bundle child payloads. WP_Context and preview close their library connection; the node path migrates once. Pinned option missing warns. CLAUDE.md says which parts of the schema machine are wired; js budget 473 KB.
418 lines
15 KiB
Python
418 lines
15 KiB
Python
"""Tests for POST /wp/api/import/commit — Task 14 / importer-exporter v2.
|
|
|
|
Endpoint wraps ``engine.importer.commit_import``. Body buckets ``adds``,
|
|
``replaces``, ``renames`` are applied atomically. Success returns
|
|
``{"ok": True, "undo_entry_id": str, "summary": {...}}`` with status
|
|
200; contract violations and wrapped DB errors return
|
|
``{"error": str}`` with status 400.
|
|
"""
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
# ── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _wildcard_entity(id_: str, name: str = "color") -> dict:
|
|
return {
|
|
"id": id_,
|
|
"type": "wildcard",
|
|
"name": name,
|
|
"description": "",
|
|
"category_id": None,
|
|
"tags": [],
|
|
"is_favorite": False,
|
|
"payload": {"options": [{"id": "r", "value": "red", "weight": 1}]},
|
|
"version": 1,
|
|
}
|
|
|
|
|
|
def _bundle_entity(id_: str, name: str = "pack") -> dict:
|
|
return {
|
|
"id": id_,
|
|
"name": name,
|
|
"description": "",
|
|
"color": None,
|
|
"category_id": None,
|
|
"tags": [],
|
|
"is_favorite": False,
|
|
"children": [],
|
|
"version": 1,
|
|
}
|
|
|
|
|
|
def _category_entity(id_: str, name: str) -> dict:
|
|
return {
|
|
"id": id_, "name": name, "color": None, "icon": None, "sort_order": 0,
|
|
}
|
|
|
|
|
|
def _template_entity(id_: str, name: str = "tpl") -> dict:
|
|
return {
|
|
"id": id_, "name": name, "description": "",
|
|
"category_id": None, "tags": [], "is_favorite": False,
|
|
"template_string": "$subject, $style",
|
|
}
|
|
|
|
|
|
async def _seed_wildcard(wp_client, name: str = "seed") -> dict:
|
|
resp = await wp_client.post("/wp/api/modules", json={
|
|
"type": "wildcard", "name": name, "description": "",
|
|
"category_id": None, "tags": [], "payload": {"options": []},
|
|
})
|
|
assert resp.status == 201
|
|
return await resp.json()
|
|
|
|
|
|
async def _seed_category(wp_client, name: str = "Style") -> dict:
|
|
resp = await wp_client.post("/wp/api/categories", json={
|
|
"name": name, "color": None, "icon": None,
|
|
})
|
|
assert resp.status == 201
|
|
return await resp.json()
|
|
|
|
|
|
# ── Happy paths ───────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_commit_add_wildcard(wp_client):
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "wildcard", "entity": _wildcard_entity("aabbccdd")}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["ok"] is True
|
|
assert isinstance(body["undo_entry_id"], str)
|
|
assert body["undo_entry_id"] # non-empty
|
|
|
|
# Confirm the module landed in the DB at the supplied id.
|
|
g = await wp_client.get("/wp/api/modules/aabbccdd")
|
|
assert g.status == 200
|
|
row = await g.json()
|
|
assert row["type"] == "wildcard"
|
|
assert row["name"] == "color"
|
|
|
|
|
|
async def test_commit_replace_wildcard(wp_client):
|
|
seed = await _seed_wildcard(wp_client, name="before")
|
|
sid = seed["id"]
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [],
|
|
"replaces": [{
|
|
"kind": "wildcard",
|
|
"id": sid,
|
|
"new_content": {
|
|
"name": "after",
|
|
"description": "renamed via commit",
|
|
"category_id": None,
|
|
"tags": ["edited"],
|
|
"is_favorite": False,
|
|
"payload": {"options": [{"id": "r", "value": "red", "weight": 1}]},
|
|
"version": 1,
|
|
},
|
|
}],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["ok"] is True
|
|
|
|
g = await wp_client.get(f"/wp/api/modules/{sid}")
|
|
assert g.status == 200
|
|
row = await g.json()
|
|
assert row["name"] == "after"
|
|
assert row["description"] == "renamed via commit"
|
|
assert row["tags"] == ["edited"]
|
|
|
|
|
|
async def test_commit_add_bundle(wp_client):
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "bundle", "entity": _bundle_entity("ccddee11", name="pack")}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["ok"] is True
|
|
assert body["undo_entry_id"]
|
|
|
|
g = await wp_client.get("/wp/api/bundles/ccddee11")
|
|
assert g.status == 200
|
|
row = await g.json()
|
|
assert row["name"] == "pack"
|
|
|
|
|
|
async def test_commit_add_template(wp_client):
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "template", "entity": _template_entity("ttee1122")}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["ok"] is True
|
|
assert body["undo_entry_id"]
|
|
|
|
g = await wp_client.get("/wp/api/templates/ttee1122")
|
|
assert g.status == 200
|
|
row = await g.json()
|
|
assert row["name"] == "tpl"
|
|
assert row["template_string"] == "$subject, $style"
|
|
|
|
|
|
async def test_commit_add_category_name_merge(wp_client):
|
|
"""Pre-seed category with name 'Style' (id derived: 'style'). Commit
|
|
another 'Style'-named category at a different id. Engine's name-based
|
|
merge collapses it — no new row, no error."""
|
|
await _seed_category(wp_client, name="Style")
|
|
# Sanity: one category present pre-commit.
|
|
listing = await (await wp_client.get("/wp/api/categories")).json()
|
|
assert len(listing["items"]) == 1
|
|
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{
|
|
"kind": "category",
|
|
"entity": _category_entity("other_id", "STYLE"), # case-insensitive
|
|
}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["ok"] is True
|
|
|
|
# Still exactly one category — the engine recorded a skip, not an insert.
|
|
after = await (await wp_client.get("/wp/api/categories")).json()
|
|
assert len(after["items"]) == 1
|
|
|
|
|
|
async def test_commit_add_fixed_values_module(wp_client):
|
|
"""Smoke test for a non-wildcard module kind — exercises the
|
|
`_is_module_kind` dispatch for fixed_values through the handler."""
|
|
entity = {
|
|
"id": "fff10001", "type": "fixed_values", "name": "fv",
|
|
"description": "", "category_id": None, "tags": [],
|
|
"is_favorite": False,
|
|
"payload": {"values": [{"id": "v1", "name": "lens", "value": "wide"}]},
|
|
"version": 1,
|
|
}
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "fixed_values", "entity": entity}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["ok"] is True
|
|
|
|
g = await wp_client.get("/wp/api/modules/fff10001")
|
|
assert g.status == 200
|
|
row = await g.json()
|
|
assert row["type"] == "fixed_values"
|
|
|
|
|
|
# ── Validation ────────────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_commit_invalid_json_returns_400(wp_client):
|
|
resp = await wp_client.post(
|
|
"/wp/api/import/commit",
|
|
data="not json",
|
|
headers={"content-type": "application/json"},
|
|
)
|
|
assert resp.status == 400
|
|
|
|
|
|
async def test_commit_non_object_body_returns_400(wp_client):
|
|
resp = await wp_client.post("/wp/api/import/commit", json=["x"])
|
|
assert resp.status == 400
|
|
|
|
|
|
async def test_commit_non_list_bucket_returns_400(wp_client):
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": "not a list",
|
|
})
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "adds" in body["error"]
|
|
|
|
|
|
# ── Contract violations → 400 ─────────────────────────────────────────────
|
|
|
|
|
|
async def test_commit_missing_entity_field_returns_400(wp_client):
|
|
"""Wildcard add missing 'name' — engine raises _ImporterContractError."""
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "wildcard", "entity": {"id": "aabbccdd"}}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
# Message should name the missing field rather than leaking SQL.
|
|
assert "name" in body["error"]
|
|
assert "wildcard" in body["error"]
|
|
|
|
|
|
async def test_commit_id_collision_on_add_returns_400(wp_client):
|
|
"""Pre-seed a module then commit an add at the same id."""
|
|
seed = await _seed_wildcard(wp_client)
|
|
sid = seed["id"]
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "wildcard", "entity": _wildcard_entity(sid)}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "collision" in body["error"]
|
|
# The colliding id must appear in the message so the SPA can highlight it.
|
|
assert sid in body["error"]
|
|
|
|
|
|
async def test_commit_unknown_kind_returns_400(wp_client):
|
|
"""Garbage kind values trip the unknown-kind branch in the engine."""
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "nonsense", "entity": _wildcard_entity("aabbccdd")}],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "unknown kind" in body["error"]
|
|
|
|
|
|
async def test_commit_rolls_back_on_partial_failure(wp_client):
|
|
"""Two adds in one commit; the second collides. The first must
|
|
NOT land — the engine's `with conn:` rolls back the whole batch."""
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [
|
|
{"kind": "wildcard", "entity": _wildcard_entity("11112222")},
|
|
# Same id collides with the first — triggers rollback.
|
|
{"kind": "wildcard", "entity": _wildcard_entity("11112222", name="dup")},
|
|
],
|
|
"replaces": [],
|
|
"renames": [],
|
|
})
|
|
assert resp.status == 400
|
|
# Confirm the first add was rolled back.
|
|
g = await wp_client.get("/wp/api/modules/11112222")
|
|
assert g.status == 404
|
|
|
|
|
|
# ── categories referenced by the same commit ──────────────────────────────
|
|
|
|
|
|
async def test_categories_listed_after_their_members_still_commit(wp_client):
|
|
"""The SPA lists category adds last; members reference them by id, so the
|
|
importer inserts categories first instead of tripping the foreign key."""
|
|
entity = {**_wildcard_entity("cat00001"), "category_id": "catnew01"}
|
|
resp = await wp_client.post("/wp/api/import/commit", json={"adds": [
|
|
{"kind": "wildcard", "entity": entity},
|
|
{"kind": "category", "entity": {"id": "catnew01", "name": "Animals"}},
|
|
]})
|
|
assert resp.status == 200, await resp.text()
|
|
got = await (await wp_client.get("/wp/api/modules/cat00001")).json()
|
|
assert got["category_id"] == "catnew01"
|
|
|
|
|
|
async def test_members_follow_a_category_merged_by_name(wp_client):
|
|
created = await wp_client.post("/wp/api/categories", json={"name": "Animals"})
|
|
existing_id = (await created.json())["id"]
|
|
entity = {**_wildcard_entity("cat00002"), "category_id": "catother"}
|
|
resp = await wp_client.post("/wp/api/import/commit", json={"adds": [
|
|
{"kind": "wildcard", "entity": entity},
|
|
{"kind": "category", "entity": {"id": "catother", "name": "animals"}},
|
|
]})
|
|
assert resp.status == 200, await resp.text()
|
|
got = await (await wp_client.get("/wp/api/modules/cat00002")).json()
|
|
assert got["category_id"] == existing_id
|
|
|
|
|
|
async def test_unknown_category_ids_are_dropped_not_fatal(wp_client):
|
|
entity = {**_wildcard_entity("cat00003"), "category_id": "nowhere1"}
|
|
resp = await wp_client.post("/wp/api/import/commit", json={"adds": [
|
|
{"kind": "wildcard", "entity": entity},
|
|
]})
|
|
assert resp.status == 200, await resp.text()
|
|
got = await (await wp_client.get("/wp/api/modules/cat00003")).json()
|
|
assert got["category_id"] is None
|
|
|
|
|
|
# ── payload shape validation (review 2026-10-16) ────────────────────────────
|
|
|
|
|
|
async def _library_ids(wp_client) -> set[str]:
|
|
resp = await wp_client.get("/wp/api/modules?limit=500")
|
|
body = await resp.json()
|
|
rows = body.get("items", body) if isinstance(body, dict) else body
|
|
return {r["id"] for r in rows}
|
|
|
|
|
|
async def test_commit_rejects_wildcard_with_malformed_options(wp_client):
|
|
bad = _wildcard_entity("aa000001")
|
|
bad["payload"] = {"options": "oops"}
|
|
good = _wildcard_entity("aa000002", name="fine")
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "wildcard", "entity": good}, {"kind": "wildcard", "entity": bad}],
|
|
})
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert body["error"].startswith("adds[1].payload")
|
|
# Nothing from the commit landed (the good one included).
|
|
assert not {"aa000001", "aa000002"} & await _library_ids(wp_client)
|
|
|
|
|
|
async def test_commit_rejects_non_object_payload_on_replace(wp_client):
|
|
seeded = await _seed_wildcard(wp_client)
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"replaces": [{
|
|
"kind": "wildcard", "id": seeded["id"],
|
|
"new_content": {**_wildcard_entity(seeded["id"]), "payload": ["x"]},
|
|
}],
|
|
})
|
|
assert resp.status == 400
|
|
assert "replaces[0].payload must be an object" in (await resp.json())["error"]
|
|
|
|
|
|
async def test_commit_rejects_bad_bundle_child(wp_client):
|
|
bundle = _bundle_entity("bb000001")
|
|
bundle["children"] = [{"id": "cc000001", "type": "combine", "payload": {"output_var": 5}}]
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [{"kind": "bundle", "entity": bundle}],
|
|
})
|
|
assert resp.status == 400
|
|
assert (await resp.json())["error"].startswith("adds[0].children[0].payload")
|
|
|
|
|
|
async def test_commit_hydrates_constraint_exceptions_from_the_same_pack(wp_client):
|
|
src = _wildcard_entity("aa000011", name="src")
|
|
tgt = _wildcard_entity("aa000012", name="tgt")
|
|
tgt["payload"] = {"options": [{"id": "b", "value": "blue", "weight": 1}]}
|
|
constraint = {
|
|
"id": "cc000011", "name": "link", "description": "", "category_id": None,
|
|
"tags": [], "is_favorite": False, "version": 1,
|
|
"payload": {
|
|
"source_wildcard_id": "aa000011", "target_wildcard_id": "aa000012",
|
|
"matrix": {},
|
|
# Option ids only: the text comes from the wildcards in this pack.
|
|
"exceptions": [{"source_id": "r", "target_id": "b", "mode": "exclude", "factor": 0}],
|
|
},
|
|
}
|
|
resp = await wp_client.post("/wp/api/import/commit", json={
|
|
"adds": [
|
|
{"kind": "wildcard", "entity": src},
|
|
{"kind": "wildcard", "entity": tgt},
|
|
{"kind": "constraint", "entity": constraint},
|
|
],
|
|
})
|
|
assert resp.status == 200, await resp.text()
|
|
resp = await wp_client.get("/wp/api/modules/cc000011")
|
|
row = await resp.json()
|
|
row = row.get("data", row)
|
|
exc = row["payload"]["exceptions"][0]
|
|
assert exc["source_value"] == "red" and exc["target_value"] == "blue"
|