diff --git a/btcopilot/schema.py b/btcopilot/schema.py index 27a6bfc..37c3991 100644 --- a/btcopilot/schema.py +++ b/btcopilot/schema.py @@ -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: @@ -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: diff --git a/btcopilot/tests/personal/test_pdp.py b/btcopilot/tests/personal/test_pdp.py index a21f0aa..79b9575 100644 --- a/btcopilot/tests/personal/test_pdp.py +++ b/btcopilot/tests/personal/test_pdp.py @@ -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( @@ -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 ─────────────────────────────────────────