Files
DumiFlex d2042ecb3a fix(bundles): fill a child's missing payload hash so a new bundle isn't drifted (#316)
* fix(bundles): fill a child's missing payload hash so a new bundle isn't drifted

A bundle child saved without payload_hash (hand-made or imported packs,
like the tutorial library) came onto the canvas with no hash, and the
canvas reads a hashless row whose id is in the library as drifted. GET
now gives such a child the hash of its own payload.

* docs(release-notes): bundle children no longer drifted on insert
2026-11-09 22:45:35 +00:00

353 lines
15 KiB
Python

"""Tier-2 bundle nesting — reference model.
Bundles store nested bundle children as REFERENCES (id-only pointers),
not deep snapshots. The API expands references inline at GET time so the
SPA pre-flatten path keeps working without knowing the difference, but
the DB only stores the pointer — when the referenced bundle changes the
parent's view is naturally fresh, no drift propagation needed.
API rules enforced here:
- Writes strip everything except {id, type, name?, color?} off bundle
children, so a request body that round-tripped the GET-expanded shape
doesn't accidentally persist a stale snapshot.
- Self-include (A → A) rejected at PUT.
- Missing reference (id not in repo) rejected.
- Tier 3 (referenced bundle already has bundle children) rejected.
- GET expands every reference inline so the SPA sees the inner bundle's
current children verbatim under the same `children` key.
"""
import pytest
pytestmark = pytest.mark.asyncio
def _leaf_wildcard(child_id: str = "aaaa0001") -> dict:
return {"id": child_id, "type": "wildcard", "payload": {"options": []}}
def _bundle_ref(ref_id: str, name: str = "ref") -> dict:
"""Minimal bundle reference shape the SPA sends on save."""
return {"id": ref_id, "type": "bundle", "name": name}
async def _create_bundle(wp_client, **fields) -> dict:
resp = await wp_client.post("/wp/api/bundles", json={
"name": fields.pop("name", "untitled"),
**fields,
})
assert resp.status == 201
return await resp.json()
# ── Reference storage shape ───────────────────────────────────────────────
async def test_post_strips_inner_children_from_bundle_refs(wp_client):
"""SPA may forward the GET-expanded shape on save. Server must strip
every field except the reference pointers before persisting."""
inner = await _create_bundle(wp_client, name="inner")
# Forward a ref WITH a stale `children` snapshot embedded.
polluted = {
"id": inner["id"], "type": "bundle", "name": "inner",
"color": "#abcdef",
"children": [_leaf_wildcard("snapshot")],
"_resolved_from": inner["id"],
"extra_garbage": True,
}
outer = await _create_bundle(wp_client, name="outer", children=[polluted])
# The expanded view will re-attach `children` from the live inner
# bundle, but the underlying ref should only carry the pointer
# fields — fetch raw via list (which doesn't expand) to confirm.
listing = await (await wp_client.get("/wp/api/bundles")).json()
raw = next(b for b in listing["items"] if b["id"] == outer["id"])
raw_ref = raw["children"][0]
assert raw_ref["id"] == inner["id"]
assert raw_ref["type"] == "bundle"
assert raw_ref["name"] == "inner"
assert raw_ref["color"] == "#abcdef"
# Stale snapshot stripped.
assert "children" not in raw_ref
assert "extra_garbage" not in raw_ref
assert "_resolved_from" not in raw_ref
# ── GET-time reference expansion ──────────────────────────────────────────
async def test_get_expands_bundle_refs_with_live_inner_children(wp_client):
"""GET /bundles/{id} attaches the referenced bundle's current
children under the bundle-child entry so the SPA pre-flatten sees
fresh content on every read."""
inner = await _create_bundle(
wp_client, name="inner",
children=[_leaf_wildcard("xx000001"), _leaf_wildcard("xx000002")],
)
outer = await _create_bundle(
wp_client, name="outer", children=[_bundle_ref(inner["id"])],
)
resp = await wp_client.get(f"/wp/api/bundles/{outer['id']}")
body = await resp.json()
[ref] = body["children"]
assert ref["type"] == "bundle"
assert ref["id"] == inner["id"]
assert [c["id"] for c in ref["children"]] == ["xx000001", "xx000002"]
assert ref["_resolved_from"] == inner["id"]
async def test_get_reflects_inner_bundle_update_without_outer_write(wp_client):
"""The reference model's headline benefit: update the inner bundle,
fetch the outer, see the new contents — no propagation required."""
inner = await _create_bundle(
wp_client, name="inner", children=[_leaf_wildcard("v1")],
)
outer = await _create_bundle(
wp_client, name="outer", children=[_bundle_ref(inner["id"])],
)
# Update inner — change its children.
await wp_client.put(f"/wp/api/bundles/{inner['id']}", json={
"children": [_leaf_wildcard("v2"), _leaf_wildcard("v3")],
})
# GET outer; expansion picks up the new state.
body = await (await wp_client.get(f"/wp/api/bundles/{outer['id']}")).json()
[ref] = body["children"]
assert [c["id"] for c in ref["children"]] == ["v2", "v3"]
async def test_get_flags_missing_reference(wp_client):
"""If the referenced bundle is deleted, GET still returns the parent
but the ref entry is marked so the SPA can show a placeholder. The
cached display name written at insert time survives the deletion."""
inner = await _create_bundle(wp_client, name="will-be-deleted")
outer = await _create_bundle(
wp_client, name="outer",
children=[_bundle_ref(inner["id"], name="will-be-deleted")],
)
await wp_client.delete(f"/wp/api/bundles/{inner['id']}")
body = await (await wp_client.get(f"/wp/api/bundles/{outer['id']}")).json()
[ref] = body["children"]
assert ref["_missing_ref"] is True
assert ref["name"] == "will-be-deleted"
# ── Validation: tier-2 rule ───────────────────────────────────────────────
async def test_post_allows_tier1_bundle_reference(wp_client):
"""Inner bundle has no bundle children → fine."""
inner = await _create_bundle(
wp_client, name="inner", children=[_leaf_wildcard()],
)
resp = await wp_client.post("/wp/api/bundles", json={
"name": "outer",
"children": [_bundle_ref(inner["id"])],
})
assert resp.status == 201
async def test_post_rejects_tier3_reference(wp_client):
"""Referenced bundle already contains a bundle child → reject."""
# Build a tier-1 bundle B.
inner_b = await _create_bundle(wp_client, name="inner-b")
# Build a tier-2 bundle M that references B (legal — M itself is
# tier 2, not stored as a tier-3 from anyone's view yet).
middle = await _create_bundle(
wp_client, name="middle", children=[_bundle_ref(inner_b["id"])],
)
# Now try to make M a child of OUTER. OUTER would reference M, and M
# contains a bundle child — Tier 3 from OUTER's perspective.
resp = await wp_client.post("/wp/api/bundles", json={
"name": "outer",
"children": [_bundle_ref(middle["id"])],
})
assert resp.status == 400
body = await resp.json()
assert "tier 2" in body["error"].lower()
async def test_post_rejects_missing_reference(wp_client):
"""Dangling pointer at write time → reject."""
resp = await wp_client.post("/wp/api/bundles", json={
"name": "outer",
"children": [_bundle_ref("deadbeef")],
})
assert resp.status == 400
body = await resp.json()
assert "not found" in body["error"].lower()
async def test_put_rejects_self_include(wp_client):
"""A → A: a child references the parent's own id."""
outer = await _create_bundle(wp_client, name="outer")
resp = await wp_client.put(f"/wp/api/bundles/{outer['id']}", json={
"children": [_bundle_ref(outer["id"])],
})
assert resp.status == 400
body = await resp.json()
assert "itself" in body["error"].lower()
async def test_put_allows_swapping_refs(wp_client):
"""Replacing one tier-1 reference with another is fine."""
a = await _create_bundle(wp_client, name="a", children=[_leaf_wildcard("a")])
b = await _create_bundle(wp_client, name="b", children=[_leaf_wildcard("b")])
outer = await _create_bundle(
wp_client, name="outer", children=[_bundle_ref(a["id"])],
)
resp = await wp_client.put(f"/wp/api/bundles/{outer['id']}", json={
"children": [_bundle_ref(b["id"])],
})
assert resp.status == 200
body = await resp.json()
[ref] = body["children"]
assert ref["id"] == b["id"]
assert [c["id"] for c in ref["children"]] == ["b"]
# ── Mixed children (leaves + refs) ────────────────────────────────────────
async def test_post_allows_mixed_leaf_and_reference(wp_client):
inner = await _create_bundle(
wp_client, name="inner", children=[_leaf_wildcard("inner-leaf")],
)
resp = await wp_client.post("/wp/api/bundles", json={
"name": "outer",
"children": [
_leaf_wildcard("aaaa0001"),
_bundle_ref(inner["id"]),
_leaf_wildcard("aaaa0002"),
],
})
assert resp.status == 201
body = await resp.json()
types = [c["type"] for c in body["children"]]
assert types == ["wildcard", "bundle", "wildcard"]
# Inner expansion attached on the bundle entry.
assert [c["id"] for c in body["children"][1]["children"]] == ["inner-leaf"]
# ── Bilateral tier-2 integrity ────────────────────────────────────────────
# The write-time check on A enforces tier-2 from A's perspective, but B
# can be updated unilaterally. Without a complementary check on B's
# update, B could gain bundle children of its own and silently demote
# every parent A's reference into a tier-3 structure on read. The
# server refuses the B-side write to keep the ceiling honest.
async def test_put_rejects_adding_bundle_child_when_referenced_by_parent(wp_client):
"""B is referenced by A. B tries to add another bundle as a child —
would make A's view tier-3. Reject with 409 Conflict."""
grandchild = await _create_bundle(wp_client, name="grandchild")
# B starts as tier-1 (no bundle children).
b = await _create_bundle(
wp_client, name="b", children=[_leaf_wildcard("b-leaf")],
)
# A references B — legal (B is tier-1).
await _create_bundle(wp_client, name="a", children=[_bundle_ref(b["id"])])
# Now try to upgrade B by adding a bundle child of its own.
resp = await wp_client.put(f"/wp/api/bundles/{b['id']}", json={
"children": [_leaf_wildcard("b-leaf"), _bundle_ref(grandchild["id"])],
})
assert resp.status == 409
body = await resp.json()
assert "tier" in body["error"].lower() or "referenced" in body["error"].lower()
async def test_put_allows_adding_bundle_child_when_no_parents(wp_client):
"""Same B, no parents referencing it — adding a bundle child is fine."""
grandchild = await _create_bundle(wp_client, name="grandchild")
b = await _create_bundle(
wp_client, name="b", children=[_leaf_wildcard("b-leaf")],
)
resp = await wp_client.put(f"/wp/api/bundles/{b['id']}", json={
"children": [_leaf_wildcard("b-leaf"), _bundle_ref(grandchild["id"])],
})
assert resp.status == 200
async def test_put_allows_leaf_only_edits_when_referenced(wp_client):
"""B is referenced by A. B can still edit its leaf children — the
integrity check only fires when a bundle child appears in the update."""
b = await _create_bundle(
wp_client, name="b", children=[_leaf_wildcard("v1")],
)
await _create_bundle(wp_client, name="a", children=[_bundle_ref(b["id"])])
resp = await wp_client.put(f"/wp/api/bundles/{b['id']}", json={
"children": [_leaf_wildcard("v2"), _leaf_wildcard("v3")],
})
assert resp.status == 200
async def test_put_allows_rename_or_recolor_when_referenced(wp_client):
"""Metadata-only updates on B skip the children integrity check
entirely — no `children` key in the patch, no work to do."""
b = await _create_bundle(wp_client, name="b")
await _create_bundle(wp_client, name="a", children=[_bundle_ref(b["id"])])
resp = await wp_client.put(f"/wp/api/bundles/{b['id']}", json={
"name": "b-renamed",
"color": "#ff0000",
})
assert resp.status == 200
async def test_put_error_lists_offending_parents(wp_client):
"""The 409 body cites which parent bundles would break, so the SPA
can route the user to detach the reference first."""
grandchild = await _create_bundle(wp_client, name="grandchild")
b = await _create_bundle(wp_client, name="b")
parent_a = await _create_bundle(
wp_client, name="parent-a", children=[_bundle_ref(b["id"])],
)
parent_x = await _create_bundle(
wp_client, name="parent-x", children=[_bundle_ref(b["id"])],
)
resp = await wp_client.put(f"/wp/api/bundles/{b['id']}", json={
"children": [_bundle_ref(grandchild["id"])],
})
assert resp.status == 409
body = await resp.json()
# Both parents named — order not guaranteed by repo.list, accept either.
assert "parent-a" in body["error"] or parent_a["id"] in body["error"]
assert "parent-x" in body["error"] or parent_x["id"] in body["error"]
# ── Edge cases ────────────────────────────────────────────────────────────
async def test_post_empty_children_unaffected(wp_client):
resp = await wp_client.post("/wp/api/bundles", json={
"name": "empty",
"children": [],
})
assert resp.status == 201
# ── Child payload hashes ──────────────────────────────────────────────────
async def test_get_fills_missing_child_payload_hash(wp_client):
"""A child saved without `payload_hash` (a hand-made or imported pack)
gets its own payload's hash on GET, top level and inside a reference;
the canvas reads a hashless row whose id is in the library as drifted."""
from engine.modules.snapshot import payload_hash
leaf = {"id": "aaaa0002", "type": "wildcard",
"payload": {"options": [{"id": "o1", "value": "x", "weight": 1}]}}
inner = await _create_bundle(wp_client, name="inner", children=[leaf])
outer = await _create_bundle(
wp_client, name="outer", children=[leaf, _bundle_ref(inner["id"])],
)
resp = await wp_client.get(f"/wp/api/bundles/{outer['id']}")
body = await resp.json()
want = payload_hash(leaf["payload"])
assert body["children"][0]["payload_hash"] == want
assert body["children"][1]["children"][0]["payload_hash"] == want
async def test_get_keeps_a_stored_child_payload_hash(wp_client):
leaf = {**_leaf_wildcard("aaaa0003"), "payload_hash": "stale"}
b = await _create_bundle(wp_client, name="kept", children=[leaf])
resp = await wp_client.get(f"/wp/api/bundles/{b['id']}")
assert (await resp.json())["children"][0]["payload_hash"] == "stale"