064bb6ea65
Source CRUD (reusable, tree-scoped) and Citation create/list/soft-delete linking one source to exactly one fact (person/event/name/relationship). Editor-gated writes, privacy-filtered reads, audit throughout; tenant + existence validation on source and target. list_citations returns all tree citations so the UI can render 'sourced' indicators in one round-trip. 22 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
97 lines
3.1 KiB
Python
97 lines
3.1 KiB
Python
"""Sources and citations (the provenance spine)."""
|
|
|
|
import uuid
|
|
|
|
from tests.conftest import auth, register
|
|
|
|
|
|
async def _setup(client, email):
|
|
h = auth(await register(client, email))
|
|
tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
|
|
person = (
|
|
await client.post(
|
|
f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h
|
|
)
|
|
).json()["id"]
|
|
event = (
|
|
await client.post(
|
|
f"/api/v1/trees/{tree_id}/events",
|
|
json={"event_type": "birth", "person_id": person},
|
|
headers=h,
|
|
)
|
|
).json()["id"]
|
|
return h, tree_id, person, event
|
|
|
|
|
|
async def test_source_and_citation_flow(client):
|
|
h, tree_id, person, event = await _setup(client, "src1@example.com")
|
|
|
|
src = await client.post(
|
|
f"/api/v1/trees/{tree_id}/sources",
|
|
json={"title": "1880 Census", "repository": "NARA"},
|
|
headers=h,
|
|
)
|
|
assert src.status_code == 201, src.text
|
|
source_id = src.json()["id"]
|
|
|
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1
|
|
|
|
# Cite the source on the birth event.
|
|
cite = await client.post(
|
|
f"/api/v1/trees/{tree_id}/citations",
|
|
json={"source_id": source_id, "event_id": event, "page": "p. 4"},
|
|
headers=h,
|
|
)
|
|
assert cite.status_code == 201, cite.text
|
|
citation_id = cite.json()["id"]
|
|
|
|
citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()
|
|
assert len(citations) == 1
|
|
assert citations[0]["event_id"] == event
|
|
assert citations[0]["source_id"] == source_id
|
|
|
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h)
|
|
assert resp.status_code == 204
|
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0
|
|
|
|
|
|
async def test_citation_needs_exactly_one_target(client):
|
|
h, tree_id, person, event = await _setup(client, "src2@example.com")
|
|
source_id = (
|
|
await client.post(
|
|
f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h
|
|
)
|
|
).json()["id"]
|
|
|
|
# No target.
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h
|
|
)
|
|
assert r.status_code == 409
|
|
# Two targets.
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tree_id}/citations",
|
|
json={"source_id": source_id, "person_id": person, "event_id": event},
|
|
headers=h,
|
|
)
|
|
assert r.status_code == 409
|
|
|
|
|
|
async def test_citation_unknown_source_404(client):
|
|
h, tree_id, person, _ = await _setup(client, "src3@example.com")
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tree_id}/citations",
|
|
json={"source_id": str(uuid.uuid4()), "person_id": person},
|
|
headers=h,
|
|
)
|
|
assert r.status_code == 404
|
|
|
|
|
|
async def test_non_member_cannot_create_source(client):
|
|
h, tree_id, _, _ = await _setup(client, "src4@example.com")
|
|
other = auth(await register(client, "src-intruder@example.com"))
|
|
r = await client.post(
|
|
f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other
|
|
)
|
|
assert r.status_code == 403
|