Files
pablan/backend/tests/test_documents_api.py
T
ProfessorNovaandClaude Opus 5 784b76baf7 Pablan, as it stands
Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
2026-09-04 09:21:37 +02:00

1099 lines
39 KiB
Python

import uuid
import pytest
from httpx import AsyncClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.passwords import hash_password
from app.models import (
Chunk,
Conversation,
ConversationMode,
Department,
Document,
DocumentStatus,
DocumentVisibility,
Job,
Message,
MessageRole,
User,
UserRole,
)
from tests.embedding_stub import deterministic_embedding
from tests.fake_openai import FakeOpenAI
async def _sales_user(db: AsyncSession) -> User:
department = Department(name="Sales")
db.add(department)
await db.flush()
user = User(
email="max@test.dev",
name="Max Test",
role=UserRole.member,
password_hash=hash_password("secret123"),
department_id=department.id,
)
db.add(user)
await db.commit()
return user
async def _doc(
db: AsyncSession,
*,
title: str,
author: User,
visibility: DocumentVisibility = DocumentVisibility.public,
status: DocumentStatus = DocumentStatus.published,
) -> Document:
document = Document(
title=title,
status=status,
visibility=visibility,
content_md=f"# {title}\n\nInhalt.",
author_id=author.id,
department_id=author.department_id,
meta={},
)
db.add(document)
await db.commit()
return document
async def _login(client: AsyncClient, email: str) -> None:
response = await client.post(
"/api/auth/login", json={"email": email, "password": "secret123"}
)
assert response.status_code == 200
async def test_list_requires_auth(client: AsyncClient) -> None:
assert (await client.get("/api/documents")).status_code == 401
async def test_list_is_permission_scoped(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
max = await _sales_user(db)
await _doc(db, title="Öffentlich", author=max)
await _doc(
db,
title="Vertrieb intern",
author=max,
visibility=DocumentVisibility.department,
)
await _doc(db, title="Geheim", author=max, visibility=DocumentVisibility.restricted)
await _doc(
db, title="Mein Entwurf", author=seeded_user, status=DocumentStatus.draft
)
await _login(client, "pablo@test.dev")
titles = {d["title"] for d in (await client.get("/api/documents")).json()["items"]}
assert titles == {"Öffentlich", "Mein Entwurf"}
async def test_filters(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
await _doc(db, title="Wartungsplan", author=seeded_user)
await _doc(db, title="Urlaubsregeln", author=seeded_user)
await _login(client, "pablo@test.dev")
by_search = (
await client.get("/api/documents", params={"search": "urlaub"})
).json()["items"]
assert [d["title"] for d in by_search] == ["Urlaubsregeln"]
by_status = (await client.get("/api/documents", params={"status": "draft"})).json()[
"items"
]
assert by_status == []
async def test_detail_hides_unreadable_documents_as_404(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
max = await _sales_user(db)
secret = await _doc(
db, title="Geheim", author=max, visibility=DocumentVisibility.restricted
)
await _login(client, "pablo@test.dev")
response = await client.get(f"/api/documents/{secret.id}")
assert response.status_code == 404
assert response.json()["code"] == "not_found"
async def test_author_reads_own_draft(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
draft = await _doc(
db, title="Entwurf", author=seeded_user, status=DocumentStatus.draft
)
await _login(client, "pablo@test.dev")
response = await client.get(f"/api/documents/{draft.id}")
assert response.status_code == 200
assert response.json()["content_md"].startswith("# Entwurf")
async def test_patch_by_author_reindexes_published(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
document = await _doc(db, title="Plan", author=seeded_user)
await _login(client, "pablo@test.dev")
response = await client.patch(
f"/api/documents/{document.id}",
json={"content_md": "# Plan\n\nNeuer Inhalt."},
)
assert response.status_code == 200
jobs = (
(await db.execute(select(Job).where(Job.type == "index_document")))
.scalars()
.all()
)
assert len(jobs) == 1
assert jobs[0].payload == {"document_id": str(document.id)}
async def test_patch_that_changes_nothing_does_not_reindex(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""Every field a PATCH can touch is denormalized into the chunks, so the
only edit that costs no reindex is the one that changes nothing."""
document = await _doc(db, title="Plan", author=seeded_user)
await _login(client, "pablo@test.dev")
assert (
await client.patch(f"/api/documents/{document.id}", json={"title": "Plan"})
).status_code == 200
job_count = (
await db.execute(select(func.count(Job.id)).where(Job.type == "index_document"))
).scalar_one()
assert job_count == 0
async def test_patch_by_non_author_forbidden(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
max = await _sales_user(db)
document = await _doc(db, title="Öffentlich", author=max)
await _login(client, "pablo@test.dev")
response = await client.patch(
f"/api/documents/{document.id}", json={"title": "Gekapert"}
)
assert response.status_code == 403
assert response.json()["code"] == "forbidden"
async def test_admin_may_edit_foreign_documents(
client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User
) -> None:
document = await _doc(db, title="Plan", author=seeded_user)
await _login(client, "florian@test.dev")
response = await client.patch(
f"/api/documents/{document.id}", json={"title": "Plan v2"}
)
assert response.status_code == 200
async def test_publishing_is_the_authors_own_action(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""A draft becomes readable when its author says so: one call, indexed,
with an audit entry."""
document = await _doc(
db,
title="Wartungsbericht",
author=seeded_user,
status=DocumentStatus.draft,
)
await _login(client, "pablo@test.dev")
response = await client.post(f"/api/documents/{document.id}/publish")
assert response.status_code == 200
assert response.json()["status"] == "published"
jobs = (
(await db.execute(select(Job).where(Job.type == "index_document")))
.scalars()
.all()
)
assert len(jobs) == 1
again = await client.post(f"/api/documents/{document.id}/publish")
assert again.status_code == 409
assert again.json()["code"] == "invalid_status"
_AUTHORING_TEMPLATE = (
'id: t-notiz\nname: "Notiz"\nversion: "1.0"\nkind: authoring\n'
'persona: "Du bist ein Fachredakteur."\n'
'title_template: "Notiz von {{user.name}}"\n'
"skeleton: |\n ## Thema\n"
'sections:\n - heading: "Thema"\n hint: "Worum es geht."\n'
"metadata:\n visibility: department\n"
)
async def test_create_draft_from_a_template(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
from app.template_import import parse_template, upsert_template
row, _ = await upsert_template(db, parse_template(_AUTHORING_TEMPLATE))
await db.commit()
await _login(client, "pablo@test.dev")
response = await client.post("/api/documents", json={"template_id": str(row.id)})
assert response.status_code == 201
body = response.json()
assert body["status"] == "draft"
assert "## Thema" in body["content_md"]
assert body["title"].startswith("Notiz von")
# The template link is kept on the document (for refinement), not returned.
created = await db.get(Document, uuid.UUID(body["id"]))
assert created.meta["template"] == "t-notiz"
async def test_created_draft_is_author_only(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""A draft is the author's private working copy — invisible to others and
never indexed (only published documents are searchable)."""
await _login(client, "pablo@test.dev")
created = await client.post("/api/documents", json={"title": "Mein Entwurf"})
assert created.status_code == 201
assert created.json()["status"] == "draft"
doc_id = created.json()["id"]
await _sales_user(db)
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
assert (await client.get(f"/api/documents/{doc_id}")).status_code == 404
async def test_create_blank_requires_a_title(
client: AsyncClient, seeded_user: User
) -> None:
await _login(client, "pablo@test.dev")
response = await client.post("/api/documents", json={})
assert response.status_code == 422
assert response.json()["code"] == "title_required"
async def test_a_new_draft_can_be_published_straight_away(
client: AsyncClient, seeded_user: User
) -> None:
"""No gate in between: write, publish. There is nothing to submit to."""
await _login(client, "pablo@test.dev")
created = await client.post("/api/documents", json={"title": "Entwurf"})
doc_id = created.json()["id"]
assert created.json()["status"] == "draft"
published = await client.post(f"/api/documents/{doc_id}/publish")
assert published.status_code == 200
assert published.json()["status"] == "published"
async def test_a_review_request_grants_reading_and_editing(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""Being asked to check something is what lets you see the draft and fix
what is wrong in it — a reviewer who spots a bad number should correct it,
not file a second question."""
reviewer = await _sales_user(db)
document = await _doc(
db,
title="Zum Prüfen",
author=seeded_user,
visibility=DocumentVisibility.public,
status=DocumentStatus.draft,
)
await _login(client, "pablo@test.dev")
# Candidates are readers minus the author; a public document is readable by
# everyone, so the Sales user shows up.
candidates = (await client.get(f"/api/documents/{document.id}/reviewers")).json()
ids = [candidate["id"] for candidate in candidates]
assert str(reviewer.id) in ids
assert str(seeded_user.id) not in ids
asked = await client.post(
f"/api/documents/{document.id}/reviews",
json={
"reviewer_id": str(reviewer.id),
"question": "Stimmen die 14 Urlaubstage noch?",
},
)
assert asked.status_code == 200
assert asked.json()["open_reviews"] == 1
assert asked.json()["reviews"][0]["question"] == "Stimmen die 14 Urlaubstage noch?"
# Asking the same person twice would just duplicate the question.
twice = await client.post(
f"/api/documents/{document.id}/reviews",
json={"reviewer_id": str(reviewer.id)},
)
assert twice.status_code == 409
assert twice.json()["code"] == "review_already_open"
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
got = (await client.get(f"/api/documents/{document.id}")).json()
assert got["can_edit"] is True
assert got["reviews"][0]["is_mine"] is True
queue = (
await client.get("/api/documents", params={"assigned_to_me": "true"})
).json()
assert str(document.id) in [item["id"] for item in queue["items"]]
# The reviewer fixes it, then answers.
fixed = await client.patch(
f"/api/documents/{document.id}", json={"content_md": "14 Tage, geprüft."}
)
assert fixed.status_code == 200
review_id = got["reviews"][0]["id"]
answered = await client.post(
f"/api/documents/{document.id}/reviews/{review_id}/resolve"
)
assert answered.status_code == 200
assert answered.json()["open_reviews"] == 0
assert answered.json()["reviews"][0]["resolved_by_name"] == "Max Test"
# While it is open, the request is the ONLY reason they see it — which is
# what the UI needs to know before the answer takes the access away.
assert got["access_reason"] == "review"
# And the grant goes with the answer: the draft is the author's again.
after = await client.get(f"/api/documents/{document.id}")
assert after.status_code == 404
async def test_a_reviewer_fixes_the_text_but_does_not_re_address_it(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""Being asked is a licence to correct the content, not to decide who reads
it: publishing and visibility stay with the owner."""
reviewer = await _sales_user(db)
document = await _doc(
db,
title="Zum Prüfen",
author=seeded_user,
visibility=DocumentVisibility.public,
status=DocumentStatus.draft,
)
await _login(client, "pablo@test.dev")
await client.post(
f"/api/documents/{document.id}/reviews",
json={"reviewer_id": str(reviewer.id)},
)
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
fixed = await client.patch(
f"/api/documents/{document.id}", json={"content_md": "Korrigiert."}
)
assert fixed.status_code == 200
published = await client.post(f"/api/documents/{document.id}/publish")
assert published.status_code == 403
narrowed = await client.patch(
f"/api/documents/{document.id}", json={"visibility": "restricted"}
)
assert narrowed.status_code == 403
assert narrowed.json()["code"] == "forbidden"
async def test_an_open_question_survives_publishing(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""The point of the whole mechanism: a document can be published AND still
carry an unanswered question, which is exactly when readers need to know."""
reviewer = await _sales_user(db)
document = await _doc(
db,
title="Urlaubsanträge",
author=seeded_user,
visibility=DocumentVisibility.public,
status=DocumentStatus.published,
)
await _login(client, "pablo@test.dev")
await client.post(
f"/api/documents/{document.id}/reviews",
json={"reviewer_id": str(reviewer.id), "question": "Noch aktuell?"},
)
listed = (await client.get("/api/documents")).json()["items"]
entry = next(item for item in listed if item["id"] == str(document.id))
assert entry["status"] == "published"
assert entry["open_reviews"] == 1
async def test_a_reviewer_must_have_read_access(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""A restricted document cannot be handed to someone who could not read
it — they are neither offered nor accepted."""
outsider = await _sales_user(db)
document = await _doc(
db,
title="Vertraulich",
author=seeded_user,
visibility=DocumentVisibility.restricted,
status=DocumentStatus.draft,
)
await _login(client, "pablo@test.dev")
candidates = (await client.get(f"/api/documents/{document.id}/reviewers")).json()
assert str(outsider.id) not in [candidate["id"] for candidate in candidates]
bad = await client.post(
f"/api/documents/{document.id}/reviews",
json={"reviewer_id": str(outsider.id)},
)
assert bad.status_code == 422
assert bad.json()["code"] == "invalid_reviewer"
async def test_sharing_grants_another_department_read_access(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None
) -> None:
"""A department-visible document shared with another department becomes
readable, searchable and listable for that department — the grant plugs
straight into the existing permission filter."""
from app.rag.indexing import reindex_document
max = await _sales_user(db)
document = Document(
title="Engineering intern",
status=DocumentStatus.published,
visibility=DocumentVisibility.department,
content_md="## Verfahren\n\nInternes Verfahren der Entwicklung.",
author_id=seeded_user.id,
department_id=seeded_user.department_id,
meta={},
)
db.add(document)
await db.flush()
await reindex_document(db, document)
await db.commit()
# Before sharing, the Sales user cannot see it.
await _login(client, "max@test.dev")
assert (await client.get(f"/api/documents/{document.id}")).status_code == 404
# The author shares it with Sales.
await client.post("/api/auth/logout")
await _login(client, "pablo@test.dev")
shared = await client.put(
f"/api/documents/{document.id}/departments",
json={"department_ids": [str(max.department_id)]},
)
assert shared.status_code == 200
assert [d["name"] for d in shared.json()["shared_departments"]] == ["Sales"]
# Now the Sales user reads, lists (under the shared department) and finds it.
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
assert (await client.get(f"/api/documents/{document.id}")).status_code == 200
listed = (
await client.get(
"/api/documents", params={"department": str(max.department_id)}
)
).json()
assert "Engineering intern" in [d["title"] for d in listed["items"]]
hits = (await client.get("/api/documents/search?q=Verfahren Entwicklung")).json()
assert "Engineering intern" in [h["title"] for h in hits]
# Unsharing removes it again.
await client.post("/api/auth/logout")
await _login(client, "pablo@test.dev")
await client.put(
f"/api/documents/{document.id}/departments", json={"department_ids": []}
)
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
assert (await client.get(f"/api/documents/{document.id}")).status_code == 404
async def test_sharing_with_an_unknown_department_is_404(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
document = await _doc(db, title="Teilbar", author=seeded_user)
await _login(client, "pablo@test.dev")
response = await client.put(
f"/api/documents/{document.id}/departments",
json={"department_ids": ["00000000-0000-0000-0000-000000000000"]},
)
assert response.status_code == 404
async def test_author_never_locks_themselves_out_of_their_document(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""The author keeps read access as author, so restricting their own document
needs no confirmation and never blocks."""
document = await _doc(db, title="Meins", author=seeded_user)
await _login(client, "pablo@test.dev")
response = await client.patch(
f"/api/documents/{document.id}", json={"visibility": "restricted"}
)
assert response.status_code == 200
assert response.json()["visibility"] == "restricted"
async def test_admin_is_warned_before_editing_away_their_own_access(
client: AsyncClient, db: AsyncSession, seeded_user: User, seeded_admin: User
) -> None:
"""An admin editing a document they do not own can lose access; the change
is blocked with a warning until they explicitly confirm the override."""
# Owned by Engineering (pablo); the admin is in Administration.
document = await _doc(db, title="Fremd", author=seeded_user)
await _login(client, "florian@test.dev")
warned = await client.patch(
f"/api/documents/{document.id}", json={"visibility": "restricted"}
)
assert warned.status_code == 409
assert warned.json()["code"] == "self_lockout_warning"
confirmed = await client.patch(
f"/api/documents/{document.id}",
json={"visibility": "restricted", "confirm_lockout": True},
)
assert confirmed.status_code == 200
# Having confirmed, the admin has indeed lost access on the next read.
assert (await client.get(f"/api/documents/{document.id}")).status_code == 404
async def test_suggest_similar_for_an_unknown_conversation_is_empty(
client: AsyncClient, seeded_user: User
) -> None:
"""No conversation (or one that is not the caller's) yields no matches and
never touches the model."""
await _login(client, "pablo@test.dev")
response = await client.post(
"/api/documents/suggest-similar",
json={"conversation_id": "00000000-0000-0000-0000-000000000000"},
)
assert response.status_code == 200
assert response.json() == []
async def test_export_returns_a_zip_of_readable_documents(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
import io
import zipfile
await _doc(
db,
title="Exportierbar",
author=seeded_user,
visibility=DocumentVisibility.public,
)
await _login(client, "pablo@test.dev")
response = await client.get("/api/documents/export")
assert response.status_code == 200
assert response.headers["content-type"] == "application/zip"
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
names = archive.namelist()
assert names and all(name.endswith(".md") for name in names)
contents = "".join(archive.read(name).decode() for name in names)
assert "Exportierbar" in contents
assert "---" in contents # YAML frontmatter
async def test_delete_cascades_chunks(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
document = await _doc(db, title="Weg damit", author=seeded_user)
db.add(
Chunk(
document_id=document.id,
chunk_index=0,
content="Inhalt.",
embedding=deterministic_embedding("Inhalt."),
)
)
await db.commit()
await _login(client, "pablo@test.dev")
assert (await client.delete(f"/api/documents/{document.id}")).status_code == 204
remaining = (await db.execute(select(func.count(Chunk.id)))).scalar_one()
assert remaining == 0
@pytest.mark.parametrize("route", ["publish", "reviews"])
async def test_workflow_actions_on_an_unreadable_document_are_404(
client: AsyncClient, db: AsyncSession, seeded_user: User, route: str
) -> None:
"""Every workflow action loads the document through the read filter first,
so an unreadable one is missing rather than forbidden."""
max = await _sales_user(db)
secret = await _doc(
db,
title="Fremd",
author=max,
visibility=DocumentVisibility.restricted,
status=DocumentStatus.draft,
)
await _login(client, "pablo@test.dev")
response = await client.post(
f"/api/documents/{secret.id}/{route}", json={"reviewer_id": str(max.id)}
)
assert response.status_code == 404
async def test_archive_and_republish(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
document = await _doc(db, title="Archivierbar", author=seeded_user)
await _login(client, "pablo@test.dev")
archived = await client.patch(
f"/api/documents/{document.id}", json={"status": "archived"}
)
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
republished = await client.patch(
f"/api/documents/{document.id}", json={"status": "published"}
)
assert republished.json()["status"] == "published"
# Both transitions enqueue an index job (remove chunks / rebuild).
job_count = (
await db.execute(select(func.count(Job.id)).where(Job.type == "index_document"))
).scalar_one()
assert job_count == 2
# A draft has nothing to archive — it was never readable in the first
# place; publishing is its own endpoint, not a status field.
draft = await _doc(
db, title="Wartet", author=seeded_user, status=DocumentStatus.draft
)
blocked = await client.patch(
f"/api/documents/{draft.id}", json={"status": "archived"}
)
assert blocked.status_code == 409
assert blocked.json()["code"] == "invalid_status"
async def test_stats_counts_published_documents_and_departments(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""Landing-page growth signal: aggregates only, no titles."""
await _doc(db, title="Sichtbar", author=seeded_user)
await _doc(db, title="Entwurf", author=seeded_user, status=DocumentStatus.draft)
await _login(client, "pablo@test.dev")
stats = (await client.get("/api/documents/stats")).json()
assert stats["documents_total"] == 1 # drafts do not count as knowledge
assert stats["departments_total"] >= 1
assert "Sichtbar" not in str(stats)
async def test_stats_requires_auth(client: AsyncClient) -> None:
assert (await client.get("/api/documents/stats")).status_code == 401
async def test_search_finds_documents_by_content_and_reports_the_section(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None
) -> None:
"""Search goes through the same hybrid retrieval as the chat, so a hit
can name the section it matched."""
from app.rag.indexing import reindex_document
document = Document(
title="Wartungsplan",
status=DocumentStatus.published,
visibility=DocumentVisibility.public,
content_md="## Intervalle\n\nDie Presse wird freitags gewartet.",
author_id=seeded_user.id,
department_id=seeded_user.department_id,
meta={},
)
db.add(document)
await db.flush()
await reindex_document(db, document)
await db.commit()
await _login(client, "pablo@test.dev")
hits = (await client.get("/api/documents/search?q=Presse freitags gewartet")).json()
assert [hit["title"] for hit in hits] == ["Wartungsplan"]
# A section match carries the heading path; a title-only match would not.
assert "Intervalle" in hits[0]["heading_path"]
async def test_search_falls_back_to_titles_for_unindexed_drafts(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None
) -> None:
"""Drafts are readable but never chunked — only the title can match."""
await _doc(
db, title="Entwurf Hydraulik", author=seeded_user, status=DocumentStatus.draft
)
await _login(client, "pablo@test.dev")
hits = (await client.get("/api/documents/search?q=Hydraulik")).json()
assert len(hits) == 1
# A title-only match has no section heading path.
assert hits[0]["heading_path"] == ""
assert hits[0]["status"] == "draft"
async def test_search_never_returns_another_departments_document(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_embed: None
) -> None:
"""Same permission filter as retrieval — by construction (rule 2)."""
from app.rag.indexing import reindex_document
max = await _sales_user(db)
secret = Document(
title="Vertriebsgeheimnis",
status=DocumentStatus.published,
visibility=DocumentVisibility.department,
content_md="## Rabatte\n\nSonderrabatte für Großkunden.",
author_id=max.id,
department_id=max.department_id,
meta={},
)
db.add(secret)
await db.flush()
await reindex_document(db, secret)
await db.commit()
await _login(client, "pablo@test.dev")
hits = (await client.get("/api/documents/search?q=Sonderrabatte Großkunden")).json()
assert hits == []
# Max, who owns it, does find it.
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
hits = (await client.get("/api/documents/search?q=Sonderrabatte Großkunden")).json()
assert [hit["title"] for hit in hits] == ["Vertriebsgeheimnis"]
async def test_search_requires_a_query(client: AsyncClient, seeded_user: User) -> None:
await _login(client, "pablo@test.dev")
assert (await client.get("/api/documents/search?q=")).status_code == 422
async def test_list_is_paginated_and_sortable(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""The browse list is the one screen that grows without bound, so it
pages server-side rather than shipping the whole knowledge base."""
for index in range(5):
await _doc(db, title=f"Doc {index}", author=seeded_user)
await _login(client, seeded_user.email)
first = (
await client.get("/api/documents", params={"per_page": 2, "page": 1})
).json()
assert len(first["items"]) == 2
assert first["total"] >= 5
assert first["per_page"] == 2
second = (
await client.get("/api/documents", params={"per_page": 2, "page": 2})
).json()
assert len(second["items"]) == 2
# Pages do not overlap.
assert {d["id"] for d in first["items"]}.isdisjoint(
d["id"] for d in second["items"]
)
# Both sort orders are accepted and cover the same set on one page.
by_created = (
await client.get("/api/documents", params={"sort": "created", "per_page": 100})
).json()
by_updated = (
await client.get("/api/documents", params={"sort": "updated", "per_page": 100})
).json()
assert {d["id"] for d in by_created["items"]} == {
d["id"] for d in by_updated["items"]
}
# An out-of-range page is empty, not an error.
assert (await client.get("/api/documents", params={"page": 999})).json()[
"items"
] == []
async def test_the_total_counts_only_what_the_user_may_see(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""`total` runs over the same filters as the page — a count that ignored
permissions would leak how much exists (rule 2)."""
max_user = await _sales_user(db)
await _doc(
db,
title="Nur Engineering",
author=seeded_user,
visibility=DocumentVisibility.department,
)
await _login(client, max_user.email)
body = (await client.get("/api/documents", params={"per_page": 100})).json()
assert "Nur Engineering" not in {d["title"] for d in body["items"]}
assert body["total"] == len(body["items"])
async def test_history_records_the_lifecycle_with_actors_and_snapshots(
client: AsyncClient, seeded_user: User
) -> None:
"""Every transition is recorded newest-first; a content edit snapshots the
Markdown so a past version can be fetched and diffed."""
await _login(client, "pablo@test.dev")
created = await client.post("/api/documents", json={"title": "Verlauf"})
doc_id = created.json()["id"]
await client.patch(
f"/api/documents/{doc_id}", json={"content_md": "# Verlauf\n\nErste Fassung."}
)
await client.post(f"/api/documents/{doc_id}/publish")
history = (await client.get(f"/api/documents/{doc_id}/history")).json()
assert [event["action"] for event in history] == [
"published",
"edited",
"created",
]
assert all(event["actor_id"] == str(seeded_user.id) for event in history)
assert all(event["actor_name"] == "Pablo Test" for event in history)
# The edit carries a content snapshot; the pure transitions do not.
edited = next(event for event in history if event["action"] == "edited")
published = next(event for event in history if event["action"] == "published")
assert edited["has_snapshot"] is True
assert published["has_snapshot"] is False
version = (
await client.get(f"/api/documents/{doc_id}/versions/{edited['id']}")
).json()
assert version["content_md"] == "# Verlauf\n\nErste Fassung."
assert version["title"] == "Verlauf"
async def test_a_version_carries_the_content_it_replaced(
client: AsyncClient, seeded_user: User
) -> None:
"""A snapshot is written after its event, so a version's diff runs against
the snapshot before it — otherwise every entry would show the next entry's
change."""
await _login(client, "pablo@test.dev")
doc_id = (await client.post("/api/documents", json={"title": "Verlauf"})).json()[
"id"
]
await client.patch(
f"/api/documents/{doc_id}", json={"content_md": "Erste Fassung."}
)
await client.patch(
f"/api/documents/{doc_id}", json={"content_md": "Zweite Fassung."}
)
history = (await client.get(f"/api/documents/{doc_id}/history")).json()
assert [event["action"] for event in history] == ["edited", "edited", "created"]
newest, middle, oldest = history
versions = {
event["id"]: (
await client.get(f"/api/documents/{doc_id}/versions/{event['id']}")
).json()
for event in history
}
assert versions[newest["id"]]["content_md"] == "Zweite Fassung."
assert versions[newest["id"]]["previous_content_md"] == "Erste Fassung."
assert versions[middle["id"]]["content_md"] == "Erste Fassung."
assert (
versions[middle["id"]]["previous_content_md"]
== versions[oldest["id"]]["content_md"]
)
# Nothing preceded the first snapshot: everything in it was added.
assert versions[oldest["id"]]["previous_content_md"] is None
async def test_history_captures_who_answered_a_review_request(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""Asking and answering are both in the timeline, with the colleague — not
the author — recorded as the one who checked it."""
reviewer = await _sales_user(db)
document = await _doc(
db,
title="Delegiert",
author=seeded_user,
visibility=DocumentVisibility.public,
status=DocumentStatus.published,
)
await _login(client, "pablo@test.dev")
asked = await client.post(
f"/api/documents/{document.id}/reviews",
json={"reviewer_id": str(reviewer.id)},
)
review_id = asked.json()["reviews"][0]["id"]
await client.post("/api/auth/logout")
await _login(client, "max@test.dev")
await client.post(f"/api/documents/{document.id}/reviews/{review_id}/resolve")
history = (await client.get(f"/api/documents/{document.id}/history")).json()
assert [event["action"] for event in history[:2]] == [
"review_resolved",
"review_requested",
]
assert history[0]["actor_id"] == str(reviewer.id)
assert history[0]["actor_name"] == "Max Test"
assert history[1]["actor_name"] == "Pablo Test"
async def test_history_records_a_visibility_change(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
document = await _doc(
db,
title="Sichtbarkeit",
author=seeded_user,
visibility=DocumentVisibility.public,
)
await _login(client, "pablo@test.dev")
await client.patch(
f"/api/documents/{document.id}", json={"visibility": "department"}
)
history = (await client.get(f"/api/documents/{document.id}/history")).json()
assert history[0]["action"] == "visibility_changed"
assert history[0]["visibility"] == "department"
assert history[0]["has_snapshot"] is False
async def test_history_is_hidden_for_unreadable_documents(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""History follows the document's read gate — existence must not leak."""
max = await _sales_user(db)
secret = await _doc(
db, title="Geheim", author=max, visibility=DocumentVisibility.restricted
)
await _login(client, "pablo@test.dev")
assert (await client.get(f"/api/documents/{secret.id}/history")).status_code == 404
assert (
await client.get(f"/api/documents/{secret.id}/versions/{uuid.uuid4()}")
).status_code == 404
async def test_paging_is_stable_when_timestamps_collide(
client: AsyncClient, db: AsyncSession, seeded_user: User
) -> None:
"""Documents seeded in one transaction share a timestamp to the
microsecond. Ordering only by that timestamp is a PARTIAL order, and
Postgres may return tied rows differently per query: two pages overlap,
one document shows up twice and another is unreachable. The id is the
tiebreaker that makes the order total.
"""
for index in range(8):
await _doc(db, title=f"Gleichzeitig {index}", author=seeded_user)
await _login(client, seeded_user.email)
seen: list[str] = []
for page in (1, 2, 3):
body = (
await client.get("/api/documents", params={"per_page": 3, "page": page})
).json()
seen.extend(item["id"] for item in body["items"])
assert len(seen) == len(set(seen)), "a document appeared on more than one page"
async def _conversation_about(
db: AsyncSession, user: User, question: str
) -> Conversation:
"""A chat with one exchange in it — the thing a capture can start from."""
conversation = Conversation(mode=ConversationMode.query, user_id=user.id)
db.add(conversation)
await db.flush()
db.add_all(
[
Message(
conversation_id=conversation.id,
role=MessageRole.user,
content=question,
),
Message(
conversation_id=conversation.id,
role=MessageRole.assistant,
content="Dazu ist nichts dokumentiert.",
),
]
)
await db.commit()
return conversation
async def test_a_capture_out_of_a_chat_keeps_the_chat_as_background(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
) -> None:
"""The subject of the chat becomes the draft's background context, which is
what makes the first refinement on-topic instead of generic."""
conversation = await _conversation_about(db, seeded_user, "Wie wechsle ich das Öl?")
fake_llm.chat_responses.append({"content": '{"topic": "Ölwechsel HP-20"}'})
await _login(client, "pablo@test.dev")
response = await client.post(
"/api/documents",
json={"title": "Ölwechsel", "conversation_id": str(conversation.id)},
)
assert response.status_code == 201
document = await db.get(Document, uuid.UUID(response.json()["id"]))
assert document is not None
assert document.meta["context"] == "Ölwechsel HP-20"
assert document.meta["conversation_id"] == str(conversation.id)
async def test_extending_a_document_out_of_a_chat_keeps_the_same_background(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
) -> None:
"""Answering "this is already documented" by extending that document must
carry the chat along too — the conversation led here either way."""
document = await _doc(db, title="Wartungsplan", author=seeded_user)
conversation = await _conversation_about(db, seeded_user, "Wie wechsle ich das Öl?")
fake_llm.chat_responses.append({"content": '{"topic": "Ölwechsel HP-20"}'})
await _login(client, "pablo@test.dev")
response = await client.patch(
f"/api/documents/{document.id}",
json={"conversation_id": str(conversation.id)},
)
assert response.status_code == 200
await db.refresh(document)
assert document.meta["context"] == "Ölwechsel HP-20"
# Metadata only: nothing a chunk carries changed, so nothing is reindexed.
jobs = (
await db.execute(select(func.count(Job.id)).where(Job.type == "index_document"))
).scalar_one()
assert jobs == 0
async def test_a_foreign_conversation_contributes_no_background(
client: AsyncClient, db: AsyncSession, seeded_user: User, fake_llm: FakeOpenAI
) -> None:
"""Owner-scoped: another user's chat is not a source of context, and the
capture still succeeds."""
max = await _sales_user(db)
conversation = await _conversation_about(db, max, "Interne Rabattgrenzen?")
await _login(client, "pablo@test.dev")
response = await client.post(
"/api/documents",
json={"title": "Eigenes", "conversation_id": str(conversation.id)},
)
assert response.status_code == 201
document = await db.get(Document, uuid.UUID(response.json()["id"]))
assert document is not None
assert "context" not in document.meta
assert fake_llm.requests == []