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 == []