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
106 lines
4.7 KiB
TypeScript
106 lines
4.7 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { login as loginAs, logout } from './helpers';
|
|
|
|
// Asking a colleague to check something, end to end and without the model:
|
|
// the author asks, the document is marked as unsettled for everyone, the
|
|
// colleague answers, and the mark goes away with the answer.
|
|
|
|
test.setTimeout(120_000);
|
|
|
|
test('a question travels with the document until the colleague answers it', async ({ page }) => {
|
|
await loginAs(page, 'pablo@pablan.dev');
|
|
|
|
// A published document everyone may read, so Max is a candidate reviewer.
|
|
const created = await (
|
|
await page.request.post('/api/documents', {
|
|
data: { title: 'Notfallnummern Halle 1', visibility: 'public' }
|
|
})
|
|
).json();
|
|
const documentId = created.id;
|
|
await page.request.patch(`/api/documents/${documentId}`, {
|
|
data: { content_md: '## Notfall\n\nDie Nummer der Instandhaltung ist die 4455.' }
|
|
});
|
|
await page.request.post(`/api/documents/${documentId}/publish`);
|
|
|
|
await page.goto(`/documents/${documentId}`);
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await page.getByTestId('document-menu').click();
|
|
await page.getByTestId('document-ask-review').click();
|
|
await page.getByTestId('reviewer-select').selectOption({ label: 'Max' });
|
|
await page.getByTestId('review-question').fill('Stimmt die 4455 noch?');
|
|
await page.getByTestId('review-ask-send').click();
|
|
|
|
// The question is on the document, and it says who is waiting on whom.
|
|
await expect(page.getByTestId('open-reviews')).toContainText('Stimmt die 4455 noch?');
|
|
await expect(page.getByTestId('open-reviews')).toContainText('Max');
|
|
// The author cannot answer their own question, only drop it.
|
|
await expect(page.getByTestId('review-confirm')).toHaveCount(0);
|
|
await expect(page.getByTestId('review-close')).toBeVisible();
|
|
await logout(page);
|
|
|
|
// Max was asked: it is in his queue, he may edit it, and he answers.
|
|
await loginAs(page, 'max@pablan.dev');
|
|
await page.goto('/documents?review=1');
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await expect(page.getByTestId('document-list')).toContainText('Notfallnummern Halle 1');
|
|
await expect(page.getByTestId('open-review-badge').first()).toBeVisible();
|
|
|
|
await page.goto(`/documents/${documentId}`);
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await expect(page.getByTestId('document-edit')).toBeVisible();
|
|
await page.getByTestId('review-confirm').click();
|
|
|
|
// Answered: no open question left, and the record says who checked it.
|
|
await expect(page.getByTestId('open-reviews')).toHaveCount(0);
|
|
await expect(page.getByTestId('answered-reviews')).toContainText('Max');
|
|
// The grant went with the answer — Max may read it, not change it.
|
|
await expect(page.getByTestId('document-edit')).toHaveCount(0);
|
|
await logout(page);
|
|
|
|
await loginAs(page, 'pablo@pablan.dev');
|
|
await page.request.delete(`/api/documents/${documentId}`);
|
|
await page.request.post('/api/auth/logout');
|
|
});
|
|
|
|
test('answering on a draft hands it back instead of dropping you on a 404', async ({ page }) => {
|
|
await loginAs(page, 'pablo@pablan.dev');
|
|
const created = await (
|
|
await page.request.post('/api/documents', {
|
|
data: { title: 'Entwurf zum Gegenlesen', visibility: 'public' }
|
|
})
|
|
).json();
|
|
const documentId = created.id;
|
|
await page.request.patch(`/api/documents/${documentId}`, {
|
|
data: { content_md: '## Stand\n\nNoch nicht fertig.' }
|
|
});
|
|
const max = (
|
|
await (await page.request.get(`/api/documents/${documentId}/reviewers`)).json()
|
|
).find((candidate: { name: string }) => candidate.name === 'Max');
|
|
await page.request.post(`/api/documents/${documentId}/reviews`, {
|
|
data: { reviewer_id: max.id, question: 'Passt der Stand so?' }
|
|
});
|
|
await logout(page);
|
|
|
|
// Max only sees the draft because he was asked, and the page says so.
|
|
await loginAs(page, 'max@pablan.dev');
|
|
await page.goto(`/documents/${documentId}`);
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await expect(page.getByTestId('open-reviews')).toContainText('Passt der Stand so?');
|
|
// He can see who may read it, and change neither that nor the publish state.
|
|
await page.getByTestId('access-chip').click();
|
|
await expect(page.getByTestId('access-controls')).toBeVisible();
|
|
await expect(page.getByTestId('visibility-select')).toHaveCount(0);
|
|
await expect(page.getByTestId('share-save')).toHaveCount(0);
|
|
await page.keyboard.press('Escape');
|
|
await expect(page.getByTestId('publish-button')).toHaveCount(0);
|
|
|
|
// Answering ends that access, so the page thanks him instead of 404ing.
|
|
await page.getByTestId('review-confirm').click();
|
|
await expect(page.getByTestId('review-done')).toBeVisible();
|
|
await logout(page);
|
|
|
|
await loginAs(page, 'pablo@pablan.dev');
|
|
await page.request.delete(`/api/documents/${documentId}`);
|
|
await page.request.post('/api/auth/logout');
|
|
});
|