Files
jobtrackingapp/job-tracker-ui/mariadb-tests/save-recovery.spec.ts
T

96 lines
6.5 KiB
TypeScript

import { test, expect, type APIRequestContext } from '@playwright/test';
import { execFileSync } from 'node:child_process';
import { translations } from '../src/i18n/translations';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
const t = translations.en;
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
return { id: me.id as string, headers: { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(c => c.name === 'XSRF-TOKEN')!.value,
'X-Session-Context': me.sessionContext as string } };
}
function sql(database: string, query: string) {
expect(database).toMatch(/^jt_it_[a-f0-9]{32}$/);
const client = process.env.JOBTRACKER_MARIADB_CLIENT;
const port = process.env.JOBTRACKER_MARIADB_BROWSER_PORT;
expect(client).toBeTruthy(); expect(Number(port)).toBeGreaterThan(0); expect(Number(port)).toBeLessThan(65536);
const output = execFileSync(client!, ['--host=127.0.0.1', `--port=${Number(port)}`, '--user=root', `--database=${database}`,
'--batch', '--raw', '--skip-column-names', `--execute=${query}`], { encoding: 'utf8',
env: { ...process.env, MYSQL_PWD: 'jt-integration-only' } });
return output.trim();
}
function stored(database: string, owner: string) {
expect(owner).toMatch(/^[a-zA-Z0-9-]{1,64}$/);
return JSON.parse(sql(database, `SET TRANSACTION READ ONLY; START TRANSACTION; SELECT JSON_OBJECT('revision',c.Version,'profile',c.ProfileJson,'projection',u.ProfileCvStructureJson,'history',(SELECT COUNT(*) FROM CareerProfileVersions h WHERE h.OwnerUserId=c.OwnerUserId)) FROM CareerProfiles c JOIN AspNetUsers u ON u.Id=c.OwnerUserId WHERE c.OwnerUserId='${owner}'; COMMIT;`));
}
test('MariaDB native Career conflict and network failure retain drafts; reviewed retry persists through refresh and independent session', async ({ page, playwright }, info) => {
const database = info.config.metadata.mariaDbDatabase as string;
const server = sql(database, 'SELECT VERSION();');
expect(server).toMatch(/^12\.2\.2-MariaDB(?:-|$)/);
const admin = await playwright.request.newContext();
const second = await playwright.request.newContext();
const fresh = await playwright.request.newContext();
try {
const adminOwner = await login(admin, 'e2e@example.test');
const email = `mdb-recovery-${Date.now()}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminOwner.headers, data: { email, password, roles: [] } })).status()).toBe(200);
const owner = await login(page.request, email);
const remoteOwner = await login(second, email);
const source = await (await page.request.get(`${api}/career/profile`)).json();
const seed = await page.request.put(`${api}/career/profile`, { headers: owner.headers, data: { expectedVersion: source.revision,
profile: { version: '1', contact: { fullName: 'MariaDB recovery fixture', phone: '+47 900 00 001' }, summary: ['Initial summary'] } } });
expect(seed.status()).toBe(200);
await page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
await page.goto('/career?section=profile');
const summary = page.getByLabel(t.profileCvStructuredSummary, { exact: true });
await expect(summary).toHaveValue('Initial summary');
const draft = 'Retained MariaDB draft reviewed after conflict';
await summary.fill(draft);
const remote = await (await second.get(`${api}/career/profile`)).json();
remote.profile.contact.phone = '+47 900 00 077';
const winnerResponse = await second.put(`${api}/career/profile`, { headers: remoteOwner.headers,
data: { expectedVersion: remote.revision, profile: remote.profile } });
expect(winnerResponse.status()).toBe(200);
const winner = await winnerResponse.json();
const acknowledged = stored(database, owner.id);
expect(acknowledged.revision).toBe(winner.revision);
const conflict = page.waitForResponse(r => r.url() === `${api}/career/profile` && r.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
expect((await conflict).status()).toBe(409);
await expect(summary).toHaveValue(draft);
await expect(page.getByRole('main').getByRole('alert').filter({ hasText: t.revisionConflict })).toBeVisible();
expect(stored(database, owner.id)).toEqual(acknowledged);
await page.reload();
await expect(page.getByLabel(t.profileCvContactPhone, { exact: true })).toHaveValue('+47 900 00 077');
await summary.fill(draft);
await page.route('**/api/career/profile', route => route.request().method() === 'PUT' ? route.abort('failed') : route.continue());
const failed = page.waitForEvent('requestfailed', r => r.url() === `${api}/career/profile` && r.method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
await failed;
await expect(summary).toHaveValue(draft);
await expect(page.getByRole('button', { name: t.profileSaveChanges, exact: true })).toBeEnabled();
expect(stored(database, owner.id)).toEqual(acknowledged);
await page.unroute('**/api/career/profile');
const saving = page.waitForResponse(r => r.url() === `${api}/career/profile` && r.request().method() === 'PUT');
await page.getByRole('button', { name: t.profileSaveChanges, exact: true }).click();
const response = await saving;
expect(response.status()).toBe(200);
const saved = stored(database, owner.id);
expect(saved.revision).toBe(winner.revision + 1);
expect(saved.history).toBe(acknowledged.history + 1);
expect(JSON.parse(saved.profile)).toMatchObject({ summary: [draft], contact: { phone: '+47 900 00 077' } });
expect(saved.projection).toBe(saved.profile);
await page.reload();
await expect(summary).toHaveValue(draft);
await login(fresh, email);
expect((await (await fresh.get(`${api}/career/profile`)).json()).profile.summary).toEqual([draft]);
expect((await fresh.get(`${api}/profile-cv/runs`)).status()).toBe(200);
expect(stored(database, owner.id)).toEqual(saved);
await info.attach('mariadb-durable-recovery', { body: JSON.stringify({ acknowledged, saved, conflict:409, server,
limits:'Native browser/API and independent read-only MariaDB connections; controlled request abort; no production or power-loss durability claim.' }), contentType:'application/json' });
} finally { await admin.dispose(); await second.dispose(); await fresh.dispose(); }
});