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(); } });