Skip to content
7 changes: 5 additions & 2 deletions btcopilot/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ def accept_committed_delete(self, item_id: int) -> None:
for event in self.events:
if event.get("id") in ids_to_remove:
continue
if any(event.get(r) == current for r in ("person", "spouse", "child")):
if any(event.get(r) == current for r in ("person", "spouse", "child")) or current in event.get("relationshipTargets", []) or current in event.get("relationshipTriangles", []):
to_visit.append(event["id"])
for pb in self.pair_bonds:
if pb.get("id") in ids_to_remove:
Expand All @@ -859,7 +859,10 @@ def accept_committed_delete(self, item_id: int) -> None:
self.people = [p for p in self.people if p.get("id") not in ids_to_remove]
self.events = [e for e in self.events if e.get("id") not in ids_to_remove]
self.pair_bonds = [pb for pb in self.pair_bonds if pb.get("id") not in ids_to_remove]
self.pdp.delete = [d for d in self.pdp.delete if d != item_id]
self.pdp.people = [p for p in self.pdp.people if p.id not in ids_to_remove]
self.pdp.events = [e for e in self.pdp.events if e.id not in ids_to_remove]
self.pdp.pair_bonds = [pb for pb in self.pdp.pair_bonds if pb.id not in ids_to_remove]
self.pdp.delete = [d for d in self.pdp.delete if d not in ids_to_remove]
_log.info(f"Cascade-deleted committed entity {item_id} (removed {ids_to_remove})")

def reject_committed_delete(self, item_id: int) -> None:
Expand Down
193 changes: 193 additions & 0 deletions btcopilot/tests/personal/test_pdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,49 @@ def test_accept_committed_delete_cascade():
assert 10 not in diagram_data.pdp.delete


def test_accept_committed_delete_cascade_relationship_events():
diagram_data = DiagramData(
people=[{"id": 10, "name": "Alice"}, {"id": 11, "name": "Bob"}],
events=[
{
"id": 20,
"kind": "shift",
"person": 11,
"description": "x",
"dateTime": "2000-01-01",
"relationshipTargets": [10],
},
{
"id": 21,
"kind": "shift",
"person": 11,
"description": "y",
"dateTime": "2000-01-02",
"relationshipTriangles": [10],
},
],
pdp=PDP(delete=[10]),
)
diagram_data.accept_committed_delete(10)
assert all(p["id"] != 10 for p in diagram_data.people)
assert diagram_data.events == []


def test_accept_committed_delete_clears_pdp_staging():
diagram_data = DiagramData(
people=[{"id": 10, "name": "Alice"}],
pdp=PDP(
people=[Person(id=10, name="Alicia")],
delete=[10],
),
)
diagram_data.accept_committed_delete(10)
assert all(p["id"] != 10 for p in diagram_data.people)
assert diagram_data.pdp.people == []
assert diagram_data.pdp.delete == []



def test_reject_committed_delete_clears_queue():
"""reject_committed_delete drops the pending delete; committed entity unchanged."""
diagram_data = DiagramData(
Expand Down Expand Up @@ -1022,6 +1065,156 @@ def test_accept_committed_delete_raises_on_missing():
diagram_data.accept_committed_delete(10)


# ── FD-333: adversarial cascade-delete tests ────────────────────────────────


def test_accept_committed_delete_cascade_via_relationship_targets_leaves_unrelated():
"""Event linked only via relationshipTargets is deleted; unrelated event survives."""
diagram_data = DiagramData(
people=[
{"id": 10, "name": "Alice"},
{"id": 11, "name": "Bob"},
{"id": 12, "name": "Carol"},
],
events=[
{
"id": 20,
"kind": "shift",
"person": 11,
"description": "rt",
"dateTime": "2000-01-01",
"relationshipTargets": [10],
"relationshipTriangles": [],
},
{
"id": 21,
"kind": "shift",
"person": 12,
"description": "unrelated",
"dateTime": "2000-01-02",
"relationshipTargets": [],
"relationshipTriangles": [],
},
],
pdp=PDP(delete=[10]),
)
diagram_data.accept_committed_delete(10)
assert all(p["id"] != 10 for p in diagram_data.people)
assert all(e["id"] != 20 for e in diagram_data.events)
assert any(e["id"] == 21 for e in diagram_data.events)


def test_accept_committed_delete_cascade_via_relationship_triangles_leaves_unrelated():
"""Event linked only via relationshipTriangles is deleted; unrelated event survives."""
diagram_data = DiagramData(
people=[
{"id": 10, "name": "Alice"},
{"id": 11, "name": "Bob"},
{"id": 12, "name": "Carol"},
],
events=[
{
"id": 20,
"kind": "shift",
"person": 11,
"description": "tri",
"dateTime": "2000-01-01",
"relationshipTargets": [],
"relationshipTriangles": [10],
},
{
"id": 21,
"kind": "shift",
"person": 12,
"description": "unrelated",
"dateTime": "2000-01-02",
"relationshipTargets": [],
"relationshipTriangles": [],
},
],
pdp=PDP(delete=[10]),
)
diagram_data.accept_committed_delete(10)
assert all(p["id"] != 10 for p in diagram_data.people)
assert all(e["id"] != 20 for e in diagram_data.events)
assert any(e["id"] == 21 for e in diagram_data.events)


def test_accept_committed_delete_clears_pdp_events_and_pair_bonds():
"""Cascaded delete clears pdp.events and pdp.pair_bonds for all removed IDs."""
diagram_data = DiagramData(
people=[
{"id": 10, "name": "Alice"},
{"id": 11, "name": "Bob"},
],
pair_bonds=[{"id": 30, "person_a": 10, "person_b": 11}],
events=[
{"id": 20, "kind": "shift", "person": 10, "description": "x",
"dateTime": "2000-01-01", "relationshipTargets": [], "relationshipTriangles": []},
],
pdp=PDP(
events=[Event(id=20, kind=EventKind.Shift, person=10, description="edited")],
pair_bonds=[PairBond(id=30, person_a=10, person_b=11)],
delete=[10],
),
)
diagram_data.accept_committed_delete(10)
assert diagram_data.pdp.events == []
assert diagram_data.pdp.pair_bonds == []
assert diagram_data.pdp.delete == []


def test_accept_committed_delete_clears_cascaded_ids_from_pdp_delete():
"""If a cascade-deleted entity also has its own pdp.delete entry, that entry is cleared too."""
diagram_data = DiagramData(
people=[
{"id": 10, "name": "Alice"},
{"id": 11, "name": "Bob"},
],
pair_bonds=[{"id": 30, "person_a": 10, "person_b": 11}],
events=[],
pdp=PDP(delete=[10, 30]), # both root and cascade target staged for delete
)
diagram_data.accept_committed_delete(10)
assert diagram_data.pair_bonds == []
assert diagram_data.pdp.delete == []


def test_accept_committed_delete_multihop_cascade_cleans_all_pdp_collections():
"""Alice(10) -> pair_bond(30) -> child(12) cascade: pair_bond, child, and birth
event are all removed from diagram and all pdp staging collections.
Bob(11) is the other pair_bond endpoint and survives (pair_bond deletion does
not cascade to the other person)."""
diagram_data = DiagramData(
people=[
{"id": 10, "name": "Alice"},
{"id": 11, "name": "Bob"},
{"id": 12, "name": "Child", "parents": 30},
],
pair_bonds=[{"id": 30, "person_a": 10, "person_b": 11}],
events=[
{"id": 20, "kind": "birth", "person": 10, "spouse": 11, "child": 12,
"dateTime": "2000-01-01", "relationshipTargets": [], "relationshipTriangles": []},
],
pdp=PDP(
people=[Person(id=12, name="Child edited")],
events=[Event(id=20, kind=EventKind.Birth, person=10, spouse=11, child=12)],
pair_bonds=[PairBond(id=30, person_a=10, person_b=11)],
delete=[10],
),
)
diagram_data.accept_committed_delete(10)
surviving_ids = {p["id"] for p in diagram_data.people}
assert surviving_ids == {11} # Bob survives; Alice and Child are gone
assert diagram_data.events == []
assert diagram_data.pair_bonds == []
assert diagram_data.pdp.people == []
assert diagram_data.pdp.events == []
assert diagram_data.pdp.pair_bonds == []
assert diagram_data.pdp.delete == []



# ── infer_parents_from_birth_events ─────────────────────────────────────────


Expand Down
Loading