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
1099 lines
39 KiB
Python
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 == []
|