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
130 lines
5.5 KiB
TypeScript
130 lines
5.5 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import { login, DEV_PASSWORD } from './helpers';
|
|
|
|
// Uses max@pablan.dev so a failure here cannot lock the other specs out of
|
|
// pablo@pablan.dev. The password is changed back at the end (zero residue).
|
|
|
|
const NEW_PASSWORD = 'ben-neues-geheimnis';
|
|
|
|
test('a user changes their own password and stays signed in', async ({ page }) => {
|
|
await login(page, 'max@pablan.dev');
|
|
|
|
await page.getByTestId('user-menu').click();
|
|
await page.getByTestId('change-password').click();
|
|
|
|
await page.getByLabel('Current password').fill(DEV_PASSWORD);
|
|
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
await page.getByLabel('Repeat new password').fill(NEW_PASSWORD);
|
|
await page.getByTestId('submit-password').click();
|
|
|
|
await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 });
|
|
|
|
// This session survived the change — no redirect to the login page.
|
|
await page.reload();
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await expect(page).toHaveURL('/');
|
|
|
|
// The new password is the one that works now.
|
|
await page.request.post('/api/auth/logout');
|
|
const stale = await page.request.post('/api/auth/login', {
|
|
data: { email: 'max@pablan.dev', password: DEV_PASSWORD }
|
|
});
|
|
expect(stale.status()).toBe(401);
|
|
|
|
// Change it back, so the suite can run again.
|
|
await login(page, 'max@pablan.dev', NEW_PASSWORD);
|
|
await page.getByTestId('user-menu').click();
|
|
await page.getByTestId('change-password').click();
|
|
await page.getByLabel('Current password').fill(NEW_PASSWORD);
|
|
await page.getByLabel('New password', { exact: true }).fill(DEV_PASSWORD);
|
|
await page.getByLabel('Repeat new password').fill(DEV_PASSWORD);
|
|
await page.getByTestId('submit-password').click();
|
|
await expect(page.getByTestId('password-changed')).toBeVisible({ timeout: 15_000 });
|
|
|
|
await page.request.post('/api/auth/logout');
|
|
});
|
|
|
|
test('the wrong current password is rejected', async ({ page }) => {
|
|
await login(page, 'max@pablan.dev');
|
|
|
|
await page.getByTestId('user-menu').click();
|
|
await page.getByTestId('change-password').click();
|
|
await page.getByLabel('Current password').fill('definitely-wrong');
|
|
await page.getByLabel('New password', { exact: true }).fill(NEW_PASSWORD);
|
|
await page.getByLabel('Repeat new password').fill(NEW_PASSWORD);
|
|
await page.getByTestId('submit-password').click();
|
|
|
|
await expect(page.getByRole('alert')).toContainText('current password');
|
|
// The old password still works.
|
|
await page.request.post('/api/auth/logout');
|
|
const still = await page.request.post('/api/auth/login', {
|
|
data: { email: 'max@pablan.dev', password: DEV_PASSWORD }
|
|
});
|
|
expect(still.status()).toBe(200);
|
|
await page.request.post('/api/auth/logout');
|
|
});
|
|
|
|
test('the theme survives a reload without flashing the other one', async ({ page }) => {
|
|
await login(page, 'max@pablan.dev');
|
|
|
|
await page.getByTestId('user-menu').click();
|
|
await page.getByTestId('settings-dialog').waitFor();
|
|
await page.getByTestId('theme-switch').getByRole('button', { name: 'Light' }).click();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
|
|
// The boot script applies it before hydration, so it is already correct
|
|
// on the very first frame after a reload.
|
|
await page.reload();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
await page.locator('body[data-hydrated]').waitFor();
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
|
|
// Back to following the OS, so the next spec starts clean.
|
|
await page.getByTestId('user-menu').click();
|
|
await page.getByTestId('theme-switch').getByRole('button', { name: 'System' }).click();
|
|
await expect(page.locator('html')).not.toHaveAttribute('data-theme', /.*/);
|
|
await page.request.post('/api/auth/logout');
|
|
});
|
|
|
|
test('the language switch flips the interface in place, with no reload', async ({ page }) => {
|
|
await login(page, 'max@pablan.dev');
|
|
|
|
await page.getByTestId('user-menu').click();
|
|
const dialog = page.getByTestId('settings-dialog');
|
|
const locales = page.getByTestId('locale-switch');
|
|
|
|
// Start from English, whatever a previous run left behind.
|
|
await locales.getByRole('button', { name: 'English' }).click();
|
|
await expect(dialog).toContainText('Settings');
|
|
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
|
|
|
|
// A marker on window survives a re-render but not a reload, and a typed
|
|
// value survives neither if the DOM is rebuilt: together they are the
|
|
// assertion that "no reload artifacts" actually holds.
|
|
await page.evaluate(() => ((window as unknown as Record<string, string>).__i18nMarker = 'alive'));
|
|
await page.getByTestId('change-password').click();
|
|
await page.getByLabel('Current password').fill('typed-before-switch');
|
|
|
|
await locales.getByRole('button', { name: 'Deutsch' }).click();
|
|
|
|
await expect(dialog).toContainText('Einstellungen');
|
|
await expect(dialog).toContainText('Passwort ändern');
|
|
await expect(page.locator('html')).toHaveAttribute('lang', 'de');
|
|
expect(
|
|
await page.evaluate(() => (window as unknown as Record<string, string>).__i18nMarker)
|
|
).toBe('alive');
|
|
await expect(page.getByLabel('Aktuelles Passwort')).toHaveValue('typed-before-switch');
|
|
|
|
// The choice is on the account, not just in the tab.
|
|
await expect
|
|
.poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale)
|
|
.toBe('de');
|
|
|
|
// Zero residue: back to following the browser.
|
|
await locales.getByRole('button', { name: 'Automatisch' }).click();
|
|
await expect
|
|
.poll(async () => (await (await page.request.get('/api/auth/me')).json()).locale)
|
|
.toBe(null);
|
|
await page.request.post('/api/auth/logout');
|
|
});
|