test(ops): verify Docker persistence, browser workflows and document deployment
This commit is contained in:
1 parent
a58516e424
commit
8fa63415f1
35 files changed
+1105
No files matched your search
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { exportRecipes } from '../../src/lib/import';
|
||||
import { pasta } from '../fixtures';
|
||||
test('admin creates and edits recipes with recoverable drafts and image upload', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('textbox', { name: 'Benutzername' }).fill('testadmin');
|
||||
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Hallo, testadmin.' })).toBeVisible();
|
||||
await page.getByRole('link', { name: 'Neues Rezept' }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Rezepttitel', exact: true })
|
||||
.fill('Browser-Küchenrezept');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Beschreibung', exact: true })
|
||||
.fill('Ein echtes Rezept aus dem grafischen Editor.');
|
||||
page.on('dialog', (d) => d.accept());
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Rezepttitel', exact: true })).toHaveValue(
|
||||
'Browser-Küchenrezept',
|
||||
);
|
||||
await page.getByLabel('Titelbild hochladen').setInputFiles('public/images/garden.jpg');
|
||||
await expect(page.getByRole('img', { name: 'Titelbild', exact: true })).toBeVisible();
|
||||
await page.getByRole('checkbox', { name: /Rezept veröffentlichen/ }).check();
|
||||
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
|
||||
await page.getByRole('spinbutton', { name: 'Menge 1', exact: true }).fill('400');
|
||||
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
|
||||
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Salz');
|
||||
await page.getByRole('combobox', { name: 'Einheit 2', exact: true }).selectOption('');
|
||||
await page.getByRole('button', { name: 'Zubereitung', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Schritttitel 1' }).fill('Kartoffeln kochen');
|
||||
await page.getByRole('textbox', { name: 'Anleitung 1' }).fill('In Salzwasser weich kochen.');
|
||||
await page.getByRole('button', { name: 'Kartoffeln', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Timer hinzufügen' }).click();
|
||||
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
|
||||
await expect(page.locator('.editor-preview')).toContainText('400 g Kartoffeln');
|
||||
await page.screenshot({ path: 'docs/screenshots/editor-desktop.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Rezept speichern' }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/rezepte\/[a-f0-9-]{36}$/);
|
||||
await page.getByRole('button', { name: 'Das Rezept', exact: true }).click();
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Rezepttitel', exact: true })
|
||||
.fill('Browser-Küchenrezept verbessert');
|
||||
await page.getByRole('button', { name: 'Rezept speichern' }).click();
|
||||
await expect(page.getByRole('status')).toContainText('erfolgreich gespeichert');
|
||||
await page.goto('/admin');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' }),
|
||||
).toBeVisible();
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page
|
||||
.getByRole('link', { name: 'Browser-Küchenrezept verbessert als JSON exportieren' })
|
||||
.click();
|
||||
const download = await downloadPromise;
|
||||
const data = JSON.parse(await readFile((await download.path())!, 'utf8'));
|
||||
expect(data.recipes[0].title).toBe('Browser-Küchenrezept verbessert');
|
||||
await page.getByRole('button', { name: 'Browser-Küchenrezept verbessert löschen' }).click();
|
||||
await page.getByRole('button', { name: 'Endgültig löschen' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await page.getByRole('button', { name: 'Abmelden' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Willkommen zurück.' })).toBeVisible();
|
||||
});
|
||||
test('JSON and XML import with a real preview, duplicate prevention and export', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByLabel('Benutzername').fill('testadmin');
|
||||
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
for (const format of ['json', 'xml'] as const) {
|
||||
await page.getByRole('button', { name: 'Importieren', exact: true }).click();
|
||||
await page.getByLabel('Importformat').selectOption(format);
|
||||
const recipe = {
|
||||
...pasta,
|
||||
id: undefined,
|
||||
title: `Importierte ${format.toUpperCase()} Pasta`,
|
||||
published: false,
|
||||
};
|
||||
await page.getByLabel('Rezeptdaten').fill(exportRecipes([recipe], format));
|
||||
await page.getByRole('button', { name: 'Importvorschau prüfen' }).click();
|
||||
await expect(page.locator('.import-preview')).toContainText(recipe.title);
|
||||
await expect(page.locator('.import-preview')).toContainText('200 g Pasta');
|
||||
await page.getByRole('button', { name: '1 Rezept(e) jetzt importieren' }).click();
|
||||
await expect(page.getByRole('heading', { name: recipe.title })).toBeVisible();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Importieren', exact: true }).click();
|
||||
await page.getByLabel('Rezeptdaten').fill(exportRecipes([pasta], 'json'));
|
||||
await page.getByRole('button', { name: 'Importvorschau prüfen' }).click();
|
||||
await expect(page.getByText('Duplikat – Import gesperrt')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '1 Rezept(e) jetzt importieren' })).toBeDisabled();
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { pasta } from '../fixtures';
|
||||
import { exportRecipes } from '../../src/lib/import';
|
||||
const headers = { origin: 'http://localhost:3100' };
|
||||
test('authorization, CSRF, optimistic concurrency, validation and transactional imports', async ({
|
||||
request,
|
||||
}) => {
|
||||
expect((await request.post('/api/recipes', { headers, data: pasta })).status()).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/auth/login', {
|
||||
headers: { origin: 'https://evil.invalid' },
|
||||
data: { username: 'testadmin', password: process.env.TEST_PASSWORD },
|
||||
})
|
||||
).status(),
|
||||
).toBe(403);
|
||||
const login = await request.post('/api/auth/login', {
|
||||
headers,
|
||||
data: { username: 'testadmin', password: process.env.TEST_PASSWORD },
|
||||
});
|
||||
expect(login.ok()).toBe(true);
|
||||
expect(login.headers()['set-cookie']).toMatch(/HttpOnly/i);
|
||||
expect(login.headers()['set-cookie']).toMatch(/SameSite=strict/i);
|
||||
const created = await request.post('/api/recipes', {
|
||||
headers,
|
||||
data: { ...pasta, id: undefined, title: 'API Rezept', published: false },
|
||||
});
|
||||
expect(created.status()).toBe(201);
|
||||
const r = await created.json();
|
||||
expect(
|
||||
(
|
||||
await request.put(`/api/recipes/${r.id}`, {
|
||||
headers,
|
||||
data: { ...r, title: 'API Rezept geändert' },
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await request.put(`/api/recipes/${r.id}`, {
|
||||
headers,
|
||||
data: { ...r, title: 'Veraltete Änderung' },
|
||||
})
|
||||
).status(),
|
||||
).toBe(409);
|
||||
expect(
|
||||
(await request.put(`/api/recipes/${r.id}`, { headers, data: { ...r, servings: 0 } })).status(),
|
||||
).toBe(422);
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/import', {
|
||||
headers,
|
||||
data: {
|
||||
format: 'json',
|
||||
preview: false,
|
||||
raw: exportRecipes(
|
||||
[{ ...pasta, id: undefined, title: 'Transaktion darf nicht bleiben' }, pasta],
|
||||
'json',
|
||||
),
|
||||
},
|
||||
})
|
||||
).status(),
|
||||
).toBe(409);
|
||||
expect(
|
||||
(await (await request.get('/api/recipes?admin=1')).json()).some(
|
||||
(r: { title: string }) => r.title === 'Transaktion darf nicht bleiben',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/import', {
|
||||
headers,
|
||||
data: {
|
||||
format: 'xml',
|
||||
preview: true,
|
||||
raw: '<!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><x/>',
|
||||
},
|
||||
})
|
||||
).status(),
|
||||
).toBe(422);
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/media', {
|
||||
headers: { ...headers, 'content-type': 'image/svg+xml' },
|
||||
data: '<svg />',
|
||||
})
|
||||
).status(),
|
||||
).toBe(415);
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/media', {
|
||||
headers: { ...headers, 'content-type': 'image/png' },
|
||||
data: 'not a png',
|
||||
})
|
||||
).status(),
|
||||
).toBe(422);
|
||||
await request.post('/api/auth/logout', { headers });
|
||||
expect((await request.get(`/api/recipes/${r.id}`)).status()).toBe(404);
|
||||
expect((await request.get('/api/export')).status()).toBe(401);
|
||||
expect((await request.delete(`/api/recipes/${r.id}`, { headers })).status()).toBe(401);
|
||||
});
|
||||
test('password change revokes other sessions and brute force is rate limited', async ({
|
||||
request,
|
||||
playwright,
|
||||
}) => {
|
||||
const other = await playwright.request.newContext({ baseURL: 'http://localhost:3100' });
|
||||
const credentials = { username: 'testadmin', password: process.env.TEST_PASSWORD };
|
||||
await request.post('/api/auth/login', { headers, data: credentials });
|
||||
await other.post('/api/auth/login', { headers, data: credentials });
|
||||
const replacement = `${process.env.TEST_PASSWORD}-changed`;
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/auth/password', {
|
||||
headers,
|
||||
data: { current: process.env.TEST_PASSWORD, password: replacement },
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
expect((await other.get('/api/export')).status()).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await request.post('/api/auth/password', {
|
||||
headers,
|
||||
data: { current: replacement, password: process.env.TEST_PASSWORD },
|
||||
})
|
||||
).status(),
|
||||
).toBe(200);
|
||||
for (let n = 0; n < 8; n++)
|
||||
expect(
|
||||
(
|
||||
await other.post('/api/auth/login', {
|
||||
headers,
|
||||
data: { username: 'nonexistent', password: 'wrong' },
|
||||
})
|
||||
).status(),
|
||||
).toBe(401);
|
||||
expect(
|
||||
(
|
||||
await other.post('/api/auth/login', {
|
||||
headers,
|
||||
data: { username: 'nonexistent', password: 'wrong' },
|
||||
})
|
||||
).status(),
|
||||
).toBe(429);
|
||||
await other.dispose();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
const id = '10000000-0000-4000-8000-000000000001';
|
||||
test('visited cooking page remains interactive offline and uncached routes show an honest fallback', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto(`/rezepte/${id}/kochen?faktor=2`);
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => {
|
||||
const cache = await caches.open('hamham-public-v1');
|
||||
return !!(await cache.match(location.href));
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||||
await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click();
|
||||
// Wait until the cached HTML's script assets have been stored as well.
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(async () => {
|
||||
const cache = await caches.open('hamham-public-v1');
|
||||
const scripts = Array.from(document.scripts)
|
||||
.map((s) => s.src)
|
||||
.filter(Boolean);
|
||||
return (await Promise.all(scripts.map((s) => cache.match(s)))).every(Boolean);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.reload();
|
||||
await context.setOffline(true);
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Pasta trifft Pesto' })).toBeVisible();
|
||||
await page.goto('/rezepte/10000000-0000-4000-8000-999999999999');
|
||||
await expect(page.getByRole('heading', { name: 'Ein Moment ohne Netz.' })).toBeVisible();
|
||||
await context.setOffline(false);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
const id = '10000000-0000-4000-8000-000000000001';
|
||||
test('editor remains usable on the current viewport in dark mode', async ({ page }, info) => {
|
||||
await page.goto('/admin');
|
||||
await page.getByLabel('Benutzername').fill('testadmin');
|
||||
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'Neues Rezept' }).click();
|
||||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
|
||||
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
|
||||
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
|
||||
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Karotten');
|
||||
await page.getByRole('button', { name: 'Zutat 2 nach oben' }).click();
|
||||
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/editor-dark-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
test('public library, shareable search, favorites, themes and responsive layouts', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(e.message));
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: 'Zuhause schmeckt’s am besten.' })).toBeVisible();
|
||||
await expect(page.locator('.recipe-card')).toHaveCount(3);
|
||||
await expect(page.getByText('Geheimer Entwurf')).toHaveCount(0);
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
for (const card of await page.locator('.recipe-card').all())
|
||||
await expect(card).toHaveCSS('opacity', '1');
|
||||
await mkdir('docs/screenshots', { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/library-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.getByRole('textbox', { name: 'Rezepte suchen' }).fill('Basilikum');
|
||||
await expect(page.locator('.recipe-card')).toHaveCount(1);
|
||||
await expect(page).toHaveURL(/q=Basilikum/);
|
||||
await page.getByRole('button', { name: 'Suche löschen' }).click();
|
||||
await page.getByRole('button', { name: 'Grüne Pesto-Pasta als Favorit merken' }).click();
|
||||
const nav =
|
||||
page.viewportSize()!.width <= 700
|
||||
? page.getByRole('navigation', { name: 'Mobile Navigation' })
|
||||
: page.getByRole('navigation', { name: 'Hauptnavigation' });
|
||||
await nav.getByRole('link', { name: /Favoriten|Lieblingsrezepte/ }).click();
|
||||
await expect(page.locator('.recipe-card')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
await expect(page.locator('.recipe-card')).toHaveCSS('opacity', '1');
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/favorites-dark-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
test('portion calculator, ingredient anchor, shopping list and print content', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await page.goto(`/rezepte/${id}`);
|
||||
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).fill('8');
|
||||
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).press('Tab');
|
||||
await expect(page.getByRole('button', { name: 'Menge Pasta ändern' })).toHaveText('800 g');
|
||||
await expect(page.locator('.steps-list')).toContainText('800 g Pasta');
|
||||
await page.getByRole('button', { name: 'Nach Zutat', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Menge Pasta ändern' }).click();
|
||||
await page.getByRole('textbox', { name: 'Neue Menge Pasta' }).fill('0,4');
|
||||
await page.getByRole('combobox', { name: 'Einheit Pasta' }).selectOption('kg');
|
||||
await page.getByRole('button', { name: 'Menge übernehmen' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Menge Pasta ändern' })).toHaveText('400 g');
|
||||
await expect(page.locator('.panel-title')).toContainText('FÜR 4 PORTIONEN');
|
||||
await expect(page.getByRole('button', { name: 'Menge Olivenöl ändern' })).toHaveText('120 ml');
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/recipe-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
await page.getByRole('button', { name: 'Zur Einkaufsliste hinzufügen' }).click();
|
||||
await page.goto('/einkaufsliste');
|
||||
await expect(page.locator('.shopping-list')).toContainText('400 g');
|
||||
await page.locator('.shopping-item').first().click();
|
||||
await page.reload();
|
||||
await expect(page.locator('.shopping-list li.is-checked')).toHaveCount(1);
|
||||
await page.goto(`/rezepte/${id}`);
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await expect(page.locator('.site-header')).toBeHidden();
|
||||
await expect(page.locator('.ingredients-panel')).toBeVisible();
|
||||
});
|
||||
test('guided cooking restores scaled progress and timestamp timers through navigation', async ({
|
||||
page,
|
||||
}, info) => {
|
||||
await page.goto(`/rezepte/${id}/kochen?faktor=2`);
|
||||
await expect(page.locator('.cooking-instruction')).toContainText('80 g Basilikum');
|
||||
await page.locator('.step-ingredients button').first().click();
|
||||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||||
await page.screenshot({
|
||||
path: `docs/screenshots/cooking-${info.project.name}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Zurück', exact: true }).click();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Pinienkerne rösten pausieren' }).click();
|
||||
await page.getByRole('button', { name: 'Pinienkerne rösten zurücksetzen' }).click();
|
||||
await expect(page.getByRole('timer')).toHaveText('03:00');
|
||||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||||
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
|
||||
await page.getByRole('button', { name: 'Fertig gekocht' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Guten Appetit!' })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Guten Appetit!' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Erneut kochen' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Ein bisschen Vorbereitung' })).toBeVisible();
|
||||
});
|
||||
Reference in new issue
Block a user