Add sources and citations API (Phase 1: sources-first spine)
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>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user