304 lines
10 KiB
Python
304 lines
10 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
|