All checks were successful
Publish FaceAI Container / publish (push) Successful in 13m22s
- Added configuration options for audit database path and retention days in backend and processor. - Integrated audit logging in server and worker processes to track search requests, completions, and failures. - Created utility functions for reading and parsing audit logs in end-to-end tests. - Updated Docker Compose files to include audit database configuration. - Added new tests to verify audit log entries for successful and no-results searches.
627 lines
24 KiB
JavaScript
627 lines
24 KiB
JavaScript
const { test, expect } = require('@playwright/test');
|
||
const {
|
||
ensureLocalAuthenticatedRacePage,
|
||
EXPECTED_MATCH_COUNT,
|
||
FACEAI_BASE_URL,
|
||
LEGACY_BASE_URL,
|
||
LEGACY_RACE_ID,
|
||
SELFIE_NAME,
|
||
buildHandoffUrl,
|
||
buildSimulatorUrl,
|
||
getSearchArtifacts,
|
||
getSelfiePath,
|
||
readAuditArtifacts,
|
||
readUtf8
|
||
} = require('./faceai-test-utils');
|
||
|
||
const FACEAI_HOME_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/?(?:\?.*)?$/;
|
||
const FACEAI_CALLBACK_URL_RE = /http:\/\/(localhost|127\.0\.0\.1):3001\/auth\/callback\?token=/;
|
||
const LONG_TEST_TIMEOUT_MS = 3 * 60 * 1000;
|
||
const SHORT_UI_TIMEOUT_MS = 30 * 1000;
|
||
const SEARCH_COMPLETION_TIMEOUT_MS = 75 * 1000;
|
||
const LEGACY_RETURN_TIMEOUT_MS = 75 * 1000;
|
||
const FILE_CHOOSER_TIMEOUT_MS = 8 * 1000;
|
||
const FACEAI_CONSENT_HEADING_RE = /Prima di continuare|Before you continue/i;
|
||
const FACEAI_UPLOAD_HEADING_RE = /Carica il tuo selfie|Upload your selfie/i;
|
||
const LEGACY_BASE_URL_RE = new RegExp(`^${escapeRegExp(LEGACY_BASE_URL)}`);
|
||
const LEGACY_HOME_URL_RE = new RegExp(`^${escapeRegExp(`${LEGACY_BASE_URL}/index.jsp`)}$`);
|
||
|
||
function escapeRegExp(value) {
|
||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
}
|
||
|
||
function buildLegacySimulatorReturnMatcher(raceId) {
|
||
return new RegExp(`^${escapeRegExp(`${LEGACY_BASE_URL}/Foto2.abl?id_gara=${raceId}`)}.*`);
|
||
}
|
||
|
||
function assertLogDoesNotContain(content, patterns, label) {
|
||
for (const pattern of patterns) {
|
||
expect(content, `${label} should not contain ${pattern}`).not.toMatch(pattern);
|
||
}
|
||
}
|
||
|
||
async function waitForFaceAiHome(page) {
|
||
await page.waitForURL((url) => FACEAI_HOME_URL_RE.test(url.toString()), {
|
||
timeout: SHORT_UI_TIMEOUT_MS
|
||
});
|
||
|
||
const consentHeading = page.getByRole('heading', { name: FACEAI_CONSENT_HEADING_RE });
|
||
if (await consentHeading.isVisible().catch(() => false)) {
|
||
await page.getByRole('checkbox', {
|
||
name: /Confermo di aver letto l’informativa sul trattamento dei dati biometrici\.|I confirm that I have read the biometric data processing notice\./i
|
||
}).check();
|
||
await page.getByRole('button', { name: /Accetto e continuo|I agree and continue/i }).click();
|
||
}
|
||
|
||
await expect(page.getByRole('heading', { name: FACEAI_UPLOAD_HEADING_RE })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Scegli immagine|Choose image/i })).toBeVisible();
|
||
}
|
||
|
||
async function launchFromSimulator(page, options = {}) {
|
||
const simulatorUrl = buildSimulatorUrl(options);
|
||
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
|
||
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||
await expect(page.locator('select#tipoPuntoFoto')).toHaveCount(0);
|
||
await page.locator('#faceaiLaunchButton').click();
|
||
await waitForFaceAiHome(page);
|
||
return simulatorUrl;
|
||
}
|
||
|
||
async function enterViaHandoff(page, options = {}) {
|
||
await page.goto(buildHandoffUrl(options), { waitUntil: 'domcontentloaded' });
|
||
await waitForFaceAiHome(page);
|
||
}
|
||
|
||
async function readLaunchUrlFromLegacyPage(page) {
|
||
const launchUrl = await page.evaluate(() => {
|
||
return typeof buildFaceAiLaunchUrl === 'function' ? buildFaceAiLaunchUrl() : '';
|
||
});
|
||
|
||
expect(launchUrl, 'Expected the legacy race page to expose a FaceAI handoff URL builder.').toBeTruthy();
|
||
return new URL(launchUrl, LEGACY_BASE_URL);
|
||
}
|
||
|
||
async function startSearch(page, selfieName) {
|
||
const createResponsePromise = page.waitForResponse((response) => {
|
||
return response.url().includes('/api/searches')
|
||
&& response.request().method() === 'POST'
|
||
&& response.status() === 201;
|
||
});
|
||
|
||
await page.locator('input[type="file"]').setInputFiles(getSelfiePath(selfieName));
|
||
|
||
const createResponse = await createResponsePromise;
|
||
return createResponse.json();
|
||
}
|
||
|
||
async function fetchSearchStatus(page, searchId) {
|
||
return page.evaluate(async ({ searchId }) => {
|
||
const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' });
|
||
const body = await response.json().catch(() => null);
|
||
return {
|
||
statusCode: response.status,
|
||
body
|
||
};
|
||
}, { searchId });
|
||
}
|
||
|
||
async function waitForSearchCondition(page, searchId, predicate, timeoutMs = 30 * 1000) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let lastPayload = null;
|
||
|
||
while (Date.now() < deadline) {
|
||
const payload = await fetchSearchStatus(page, searchId);
|
||
lastPayload = payload;
|
||
if (payload.statusCode === 200 && predicate(payload.body)) {
|
||
return payload.body;
|
||
}
|
||
|
||
await page.waitForTimeout(250);
|
||
}
|
||
|
||
throw new Error(`Timed out waiting for search ${searchId}. Last payload: ${JSON.stringify(lastPayload)}`);
|
||
}
|
||
|
||
async function waitForLegacyResult(page, raceId, expectedMatchCount = null) {
|
||
await page.waitForURL(buildLegacySimulatorReturnMatcher(raceId), {
|
||
timeout: LEGACY_RETURN_TIMEOUT_MS,
|
||
waitUntil: 'commit'
|
||
});
|
||
|
||
await expect.poll(() => page.url(), {
|
||
timeout: 15 * 1000,
|
||
message: 'Expected the legacy race return URL to include FaceAI direct-return parameters.'
|
||
}).toMatch(/faceaiResultId=|faceaiMatchStorageKey=|faceaiPhotoIds=/);
|
||
|
||
if (expectedMatchCount === null) {
|
||
return;
|
||
}
|
||
|
||
await expect(page.locator('#faceAiPhotoCountValue')).toHaveText(String(expectedMatchCount), {
|
||
timeout: SHORT_UI_TIMEOUT_MS
|
||
});
|
||
}
|
||
|
||
async function verifySearchLogs(searchId, { expectedMatchCount, expectedSelfieName }) {
|
||
const artifacts = getSearchArtifacts(searchId);
|
||
const [backendLog, processorLog, workerLog, matcherLog] = await Promise.all([
|
||
readUtf8(artifacts.backendLogPath),
|
||
readUtf8(artifacts.processorLogPath),
|
||
readUtf8(artifacts.workerLogPath),
|
||
readUtf8(artifacts.matcherLogPath)
|
||
]);
|
||
|
||
expect(workerLog).toContain('Completed FaceAI search');
|
||
if (expectedMatchCount !== undefined) {
|
||
expect(workerLog).toContain(`"matchCount":${expectedMatchCount}`);
|
||
}
|
||
if (expectedSelfieName) {
|
||
expect(matcherLog).toContain(expectedSelfieName);
|
||
}
|
||
|
||
assertLogDoesNotContain(backendLog, [/\bnpm error\b/i, /\berror:\b/i, /\bfailed\b/i], 'backend.log');
|
||
assertLogDoesNotContain(processorLog, [new RegExp(`Failed FaceAI search ${searchId}`, 'i'), /\bnpm error\b/i], 'processor.log');
|
||
assertLogDoesNotContain(workerLog, [/FaceAI search failed/i], 'worker.log');
|
||
assertLogDoesNotContain(matcherLog, [/\[ERROR\]/i, /Traceback/i], 'matcher.log');
|
||
|
||
return { backendLog, processorLog, workerLog, matcherLog };
|
||
}
|
||
|
||
async function verifyAuditLog(searchId, { expectedMatchCount, expectedRaceId, expectedSelfieName, expectedUserId }) {
|
||
const artifacts = getSearchArtifacts(searchId);
|
||
const audit = readAuditArtifacts(searchId);
|
||
|
||
expect(audit.searchRow, `Expected ${artifacts.auditDbPath} to contain an audit row for ${searchId}`).toBeTruthy();
|
||
expect(audit.searchRow.search_id).toBe(searchId);
|
||
expect(audit.searchRow.status).toBe('completed');
|
||
expect(audit.searchRow.match_count).toBe(expectedMatchCount);
|
||
expect(audit.searchRow.race_id).toBe(expectedRaceId);
|
||
expect(audit.searchRow.user_id).toBe(expectedUserId);
|
||
expect(audit.searchRow.selfie_name).toBe(expectedSelfieName);
|
||
expect(audit.searchRow.selfie_sha256).toMatch(/^[a-f0-9]{64}$/i);
|
||
expect(audit.searchRow.selfie_size_bytes).toBeGreaterThan(0);
|
||
expect(audit.searchRow.result_id).toBeTruthy();
|
||
expect(audit.searchRow.requested_at).toBeGreaterThan(0);
|
||
expect(audit.searchRow.completed_at).toBeGreaterThan(0);
|
||
expect(audit.searchRow.redirect_issued_at).toBeGreaterThan(0);
|
||
expect(audit.searchRow.matches).toHaveLength(expectedMatchCount);
|
||
|
||
const eventTypes = audit.events.map((event) => event.event_type);
|
||
expect(eventTypes).toContain('search_requested');
|
||
expect(eventTypes).toContain('search_completed');
|
||
expect(eventTypes).toContain('search_redirect_issued');
|
||
|
||
const requestedEvent = audit.events.find((event) => event.event_type === 'search_requested');
|
||
expect(requestedEvent?.selfie_sha256).toBe(audit.searchRow.selfie_sha256);
|
||
|
||
const fingerprintMatch = audit.fingerprintMatches.find((entry) => entry.search_id === searchId);
|
||
expect(fingerprintMatch, 'Expected fingerprint lookup to find the original search row').toBeTruthy();
|
||
expect(fingerprintMatch.match_count).toBe(expectedMatchCount);
|
||
expect(fingerprintMatch.result_id).toBe(audit.searchRow.result_id);
|
||
expect(fingerprintMatch.status).toBe('completed');
|
||
|
||
return audit;
|
||
}
|
||
|
||
async function verifyNoResultsAuditLog(searchId, { expectedRaceId, expectedSelfieName, expectedUserId }) {
|
||
const artifacts = getSearchArtifacts(searchId);
|
||
const audit = readAuditArtifacts(searchId);
|
||
|
||
expect(audit.searchRow, `Expected ${artifacts.auditDbPath} to contain an audit row for ${searchId}`).toBeTruthy();
|
||
expect(audit.searchRow.search_id).toBe(searchId);
|
||
expect(audit.searchRow.status).toBe('completed');
|
||
expect(audit.searchRow.completion_code).toBe('NO_FACES_FOUND');
|
||
expect(audit.searchRow.match_count).toBe(0);
|
||
expect(audit.searchRow.race_id).toBe(expectedRaceId);
|
||
expect(audit.searchRow.user_id).toBe(expectedUserId);
|
||
expect(audit.searchRow.selfie_name).toBe(expectedSelfieName);
|
||
expect(audit.searchRow.selfie_sha256).toMatch(/^[a-f0-9]{64}$/i);
|
||
expect(audit.searchRow.selfie_size_bytes).toBeGreaterThan(0);
|
||
expect(audit.searchRow.result_id).toBeTruthy();
|
||
expect(audit.searchRow.requested_at).toBeGreaterThan(0);
|
||
expect(audit.searchRow.completed_at).toBeGreaterThan(0);
|
||
expect(audit.searchRow.redirect_issued_at).toBeNull();
|
||
expect(audit.searchRow.matches).toHaveLength(0);
|
||
|
||
const eventTypes = audit.events.map((event) => event.event_type);
|
||
expect(eventTypes).toContain('search_requested');
|
||
expect(eventTypes).toContain('search_completed');
|
||
expect(eventTypes).not.toContain('search_redirect_issued');
|
||
|
||
const requestedEvent = audit.events.find((event) => event.event_type === 'search_requested');
|
||
expect(requestedEvent?.selfie_sha256).toBe(audit.searchRow.selfie_sha256);
|
||
|
||
const fingerprintMatch = audit.fingerprintMatches.find((entry) => entry.search_id === searchId);
|
||
expect(fingerprintMatch, 'Expected fingerprint lookup to find the original search row').toBeTruthy();
|
||
expect(fingerprintMatch.match_count).toBe(0);
|
||
expect(fingerprintMatch.result_id).toBe(audit.searchRow.result_id);
|
||
expect(fingerprintMatch.status).toBe('completed');
|
||
|
||
return audit;
|
||
}
|
||
|
||
async function closeContexts(contexts) {
|
||
await Promise.all(contexts.map(async (context) => {
|
||
try {
|
||
await context.close();
|
||
} catch (error) {
|
||
if (!/ENOENT|Target page, context or browser has been closed/i.test(String(error))) {
|
||
throw error;
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
|
||
test('runs the legacy Tomcat flow through FaceAI and returns to the filtered legacy result', async ({ page }) => {
|
||
test.slow();
|
||
|
||
await launchFromSimulator(page, {
|
||
raceId: LEGACY_RACE_ID,
|
||
raceSlug: 'isolotto',
|
||
raceName: 'Festa sociale UP Isolotto',
|
||
raceFolder: 'ISOLOTTO'
|
||
});
|
||
|
||
const search = await startSearch(page, SELFIE_NAME);
|
||
|
||
await waitForLegacyResult(page, LEGACY_RACE_ID, EXPECTED_MATCH_COUNT);
|
||
|
||
await verifySearchLogs(search.id, {
|
||
expectedMatchCount: EXPECTED_MATCH_COUNT,
|
||
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
|
||
});
|
||
|
||
await verifyAuditLog(search.id, {
|
||
expectedMatchCount: EXPECTED_MATCH_COUNT,
|
||
expectedRaceId: LEGACY_RACE_ID,
|
||
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop(),
|
||
expectedUserId: 'legacy-user-1'
|
||
});
|
||
});
|
||
|
||
test('records structured logs for a completed no-results FaceAI search in the dev compose stack', async ({ page }) => {
|
||
test.slow();
|
||
|
||
await launchFromSimulator(page, {
|
||
raceId: LEGACY_RACE_ID,
|
||
raceSlug: 'isolotto',
|
||
raceName: 'Festa sociale UP Isolotto',
|
||
raceFolder: 'ISOLOTTO'
|
||
});
|
||
|
||
const search = await startSearch(page, SELFIE_NAME);
|
||
|
||
await waitForSearchCondition(page, search.id, (payload) => {
|
||
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
|
||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||
|
||
await verifySearchLogs(search.id, {
|
||
expectedMatchCount: 0,
|
||
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
|
||
});
|
||
|
||
await verifyNoResultsAuditLog(search.id, {
|
||
expectedRaceId: LEGACY_RACE_ID,
|
||
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop(),
|
||
expectedUserId: 'legacy-user-1'
|
||
});
|
||
});
|
||
|
||
test('builds the legacy FaceAI handoff URL with the exact local race storage metadata', async ({ page }) => {
|
||
await page.goto(buildSimulatorUrl({
|
||
raceId: LEGACY_RACE_ID,
|
||
raceSlug: 'isolotto',
|
||
raceName: 'Festa sociale UP Isolotto',
|
||
raceYear: '2026',
|
||
raceMonthFolder: '04.APRILE',
|
||
raceFolder: 'ISOLOTTO'
|
||
}), { waitUntil: 'domcontentloaded' });
|
||
|
||
await expect(page.locator('#faceAiRaceYear')).toHaveValue('2026');
|
||
await expect(page.locator('#faceAiRaceMonthFolder')).toHaveValue('04.APRILE');
|
||
await expect(page.locator('#faceAiRaceFolder')).toHaveValue('ISOLOTTO');
|
||
|
||
const launchUrl = await readLaunchUrlFromLegacyPage(page);
|
||
expect(launchUrl.searchParams.get('raceYear')).toBe('2026');
|
||
expect(launchUrl.searchParams.get('raceMonthFolder')).toBe('04.APRILE');
|
||
expect(launchUrl.searchParams.get('raceFolder')).toBe('ISOLOTTO');
|
||
expect(launchUrl.searchParams.get('raceStorageRelativeDir')).toBe('2026/04.APRILE/ISOLOTTO');
|
||
});
|
||
|
||
test('shows the unsupported-race message when the current race has no PKL data and lets the user go back', async ({ page }) => {
|
||
const consoleErrors = [];
|
||
page.on('console', (message) => {
|
||
if (message.type() === 'error') {
|
||
consoleErrors.push(message.text());
|
||
}
|
||
});
|
||
|
||
await launchFromSimulator(page, {
|
||
raceId: '404',
|
||
raceSlug: 'corsa-di-livorno',
|
||
raceName: 'Corsa di Livorno',
|
||
raceFolder: 'LIVORNO'
|
||
});
|
||
|
||
await expect(page.locator('.faceai-feedback')).toContainText('FaceAI non è disponibile per questa gara.');
|
||
await expect(page.locator('input[type="file"]')).toBeDisabled();
|
||
await expect(page.getByRole('button', { name: 'Scegli immagine' })).toBeDisabled();
|
||
await expect.poll(() => {
|
||
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||
}).toBeNull();
|
||
|
||
await page.waitForTimeout(2000);
|
||
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
|
||
|
||
await page.getByRole('button', { name: 'Torna alla pagina gara' }).click();
|
||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('404'));
|
||
});
|
||
|
||
test('shows a localized invalid-race error when the handoff omits race storage metadata', async ({ page }) => {
|
||
const consoleErrors = [];
|
||
page.on('console', (message) => {
|
||
if (message.type() === 'error') {
|
||
consoleErrors.push(message.text());
|
||
}
|
||
});
|
||
|
||
const handoffUrl = new URL(buildHandoffUrl({
|
||
raceId: '405',
|
||
lang: 'en',
|
||
raceSlug: 'ghost-race',
|
||
raceName: 'Ghost Race',
|
||
raceFolder: 'THIS RACE DOES NOT EXIST'
|
||
}));
|
||
handoffUrl.searchParams.delete('raceYear');
|
||
handoffUrl.searchParams.delete('raceMonthFolder');
|
||
handoffUrl.searchParams.delete('raceFolder');
|
||
|
||
await page.goto(handoffUrl.toString(), { waitUntil: 'domcontentloaded' });
|
||
|
||
await page.waitForURL(FACEAI_HOME_URL_RE, {
|
||
timeout: SHORT_UI_TIMEOUT_MS
|
||
});
|
||
await expect(page.getByRole('heading', { name: 'Find your photos with a selfie' })).toBeVisible();
|
||
await expect(page.locator('.faceai-feedback')).toContainText('The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.');
|
||
await expect(page.locator('input[type="file"]')).toBeDisabled();
|
||
await expect(page.getByRole('button', { name: 'Choose image' })).toBeDisabled();
|
||
await expect(page.getByRole('button', { name: 'Start Face ID search' })).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: 'Back to the race page' })).toBeVisible();
|
||
await expect.poll(() => {
|
||
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||
}).toContain('MISSING_RACE_STORAGE');
|
||
await expect.poll(() => {
|
||
return consoleErrors.find((entry) => entry.includes('[FaceAI] Invalid race data:')) || null;
|
||
}).toContain('MISSING_RACE_STORAGE');
|
||
|
||
await page.getByRole('button', { name: 'Back to the race page' }).click();
|
||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('405'));
|
||
});
|
||
|
||
test('rejects a not-logged-in user after clicking the Face ID button and sends them back to the original race page', async ({ page }) => {
|
||
const simulatorUrl = buildSimulatorUrl({
|
||
raceId: '202',
|
||
raceSlug: 'mezza-di-pisa',
|
||
raceName: 'Mezza di Pisa',
|
||
raceFolder: 'PISA'
|
||
});
|
||
|
||
await page.goto(simulatorUrl, { waitUntil: 'domcontentloaded' });
|
||
await expect(page.locator('#faceaiLaunchButton')).toBeVisible();
|
||
|
||
await page.evaluate(() => {
|
||
if (window.faceAiSimulator) {
|
||
delete window.faceAiSimulator.devUserId;
|
||
delete window.faceAiSimulator.devDisplayName;
|
||
delete window.faceAiSimulator.devEmail;
|
||
delete window.faceAiSimulator.devMembershipStatus;
|
||
}
|
||
});
|
||
|
||
await page.locator('#faceaiLaunchButton').click();
|
||
|
||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
|
||
await expect(page).not.toHaveURL(FACEAI_HOME_URL_RE);
|
||
await expect(page.locator('#faceAiErrorModal')).toBeVisible();
|
||
await expect(page.locator('#faceAiErrorModalLabel')).toContainText('Face ID non disponibile');
|
||
await expect(page.locator('#faceAiErrorModalMessage')).toContainText('Il servizio Face ID non e al momento disponibile. Riprova piu tardi.');
|
||
});
|
||
|
||
test('authenticates with the seeded local user and lets that user browse and launch the Livorno race page', async ({ page }) => {
|
||
await ensureLocalAuthenticatedRacePage(page, {
|
||
raceId: '1018557',
|
||
raceName: 'VIVICITTA LIVORNO',
|
||
raceYear: '2026',
|
||
raceMonthFolder: '04.APRILE',
|
||
raceFolder: 'LIVORNO'
|
||
});
|
||
|
||
await page.locator('#faceaiLaunchButton').click();
|
||
await waitForFaceAiHome(page);
|
||
});
|
||
|
||
test('shows the no-face message and allows the user to return to the race page', async ({ page }) => {
|
||
test.slow();
|
||
|
||
await launchFromSimulator(page, {
|
||
raceId: '202',
|
||
raceSlug: 'mezza-di-pisa',
|
||
raceName: 'Mezza di Pisa',
|
||
raceFolder: 'PISA'
|
||
});
|
||
|
||
const search = await startSearch(page, 'DSC_1994.JPG');
|
||
|
||
await waitForSearchCondition(page, search.id, (payload) => {
|
||
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
|
||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||
|
||
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
|
||
await page.waitForTimeout(2000);
|
||
await expect(page).toHaveURL(FACEAI_HOME_URL_RE);
|
||
|
||
await verifySearchLogs(search.id, {
|
||
expectedMatchCount: 0,
|
||
expectedSelfieName: 'DSC_1994.JPG'
|
||
});
|
||
|
||
await page.getByRole('button', { name: 'Torna alla pagina gara' }).click();
|
||
await expect(page).toHaveURL(buildLegacySimulatorReturnMatcher('202'));
|
||
});
|
||
|
||
test('opens the file chooser when the user clicks the upload button', async ({ page }) => {
|
||
await launchFromSimulator(page, {
|
||
raceId: '202',
|
||
raceSlug: 'mezza-di-pisa',
|
||
raceName: 'Mezza di Pisa',
|
||
raceFolder: 'PISA'
|
||
});
|
||
|
||
const fileChooserPromise = page.waitForEvent('filechooser', { timeout: FILE_CHOOSER_TIMEOUT_MS });
|
||
await page.getByRole('button', { name: 'Scegli immagine' }).click();
|
||
const fileChooser = await fileChooserPromise;
|
||
|
||
await fileChooser.setFiles(getSelfiePath(SELFIE_NAME));
|
||
await expect(page.getByText(SELFIE_NAME.split(/[\\/]+/u).pop())).toBeVisible();
|
||
await expect(page.getByRole('button', { name: 'Avvia ricerca Face ID' })).toBeEnabled();
|
||
});
|
||
|
||
test('lets the user retry with a valid photo after a no-face upload and then returns to the filtered legacy result', async ({ page }) => {
|
||
test.slow();
|
||
|
||
await launchFromSimulator(page, {
|
||
raceId: '202',
|
||
raceSlug: 'mezza-di-pisa',
|
||
raceName: 'Mezza di Pisa',
|
||
raceFolder: 'PISA'
|
||
});
|
||
|
||
const noFaceSearch = await startSearch(page, 'DSC_1994.JPG');
|
||
await waitForSearchCondition(page, noFaceSearch.id, (payload) => {
|
||
return payload.status === 'completed' && payload.completionCode === 'NO_FACES_FOUND';
|
||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||
|
||
await expect(page.locator('.faceai-feedback')).toContainText('Nessun volto rilevato nella foto caricata');
|
||
await expect(page.locator('input[type="file"]')).toBeEnabled();
|
||
|
||
const retrySearch = await startSearch(page, SELFIE_NAME);
|
||
await waitForLegacyResult(page, '202', EXPECTED_MATCH_COUNT);
|
||
|
||
await verifySearchLogs(noFaceSearch.id, {
|
||
expectedMatchCount: 0,
|
||
expectedSelfieName: 'DSC_1994.JPG'
|
||
});
|
||
await verifySearchLogs(retrySearch.id, {
|
||
expectedMatchCount: EXPECTED_MATCH_COUNT,
|
||
expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop()
|
||
});
|
||
});
|
||
|
||
test('redirects direct-entry users without FaceAI session data back to the legacy site', async ({ browser }) => {
|
||
const context = await browser.newContext();
|
||
const page = await context.newPage();
|
||
|
||
try {
|
||
await page.goto(`${FACEAI_BASE_URL}/`, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForURL(LEGACY_HOME_URL_RE, { timeout: SHORT_UI_TIMEOUT_MS });
|
||
} finally {
|
||
await context.close();
|
||
}
|
||
});
|
||
|
||
test('allows two users to process different photos at the same time', async ({ browser }) => {
|
||
test.slow();
|
||
test.setTimeout(LONG_TEST_TIMEOUT_MS);
|
||
|
||
const contexts = [await browser.newContext(), await browser.newContext()];
|
||
const pages = await Promise.all(contexts.map((context) => context.newPage()));
|
||
|
||
try {
|
||
await Promise.all([
|
||
enterViaHandoff(pages[0], { userId: 'concurrency-user-1' }),
|
||
enterViaHandoff(pages[1], { userId: 'concurrency-user-2' })
|
||
]);
|
||
|
||
const [searchOne, searchTwo] = await Promise.all([
|
||
startSearch(pages[0], SELFIE_NAME),
|
||
startSearch(pages[1], 'DSC_1987.JPG')
|
||
]);
|
||
|
||
await Promise.all([
|
||
waitForLegacyResult(pages[0], '202'),
|
||
waitForLegacyResult(pages[1], '202')
|
||
]);
|
||
|
||
await Promise.all([
|
||
verifySearchLogs(searchOne.id, { expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop() }),
|
||
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' })
|
||
]);
|
||
} finally {
|
||
await closeContexts(contexts);
|
||
}
|
||
});
|
||
|
||
test('queues the third user until a worker is free and then completes all three searches normally', async ({ browser }) => {
|
||
test.slow();
|
||
test.setTimeout(LONG_TEST_TIMEOUT_MS);
|
||
|
||
const contexts = [await browser.newContext(), await browser.newContext(), await browser.newContext()];
|
||
const pages = await Promise.all(contexts.map((context) => context.newPage()));
|
||
|
||
try {
|
||
await Promise.all([
|
||
enterViaHandoff(pages[0], { userId: 'queue-user-1' }),
|
||
enterViaHandoff(pages[1], { userId: 'queue-user-2' }),
|
||
enterViaHandoff(pages[2], { userId: 'queue-user-3' })
|
||
]);
|
||
|
||
const [searchOne, searchTwo, searchThree] = await Promise.all([
|
||
startSearch(pages[0], SELFIE_NAME),
|
||
startSearch(pages[1], 'DSC_1987.JPG'),
|
||
startSearch(pages[2], 'DSC_2058.JPG')
|
||
]);
|
||
|
||
const searchSessions = [
|
||
{ page: pages[0], searchId: searchOne.id },
|
||
{ page: pages[1], searchId: searchTwo.id },
|
||
{ page: pages[2], searchId: searchThree.id }
|
||
];
|
||
|
||
let queuedSearch = null;
|
||
const deadline = Date.now() + 30 * 1000;
|
||
while (Date.now() < deadline && !queuedSearch) {
|
||
const statuses = await Promise.all(searchSessions.map(async (session) => {
|
||
const payload = await fetchSearchStatus(session.page, session.searchId);
|
||
return {
|
||
...session,
|
||
search: payload.body
|
||
};
|
||
}));
|
||
|
||
const processingCount = statuses.filter((item) => item.search?.status === 'processing').length;
|
||
queuedSearch = processingCount >= 2
|
||
? statuses.find((item) => item.search?.status === 'queued') || null
|
||
: null;
|
||
|
||
if (!queuedSearch) {
|
||
await pages[0].waitForTimeout(250);
|
||
}
|
||
}
|
||
|
||
expect(queuedSearch, 'one search should remain queued while two worker slots are busy').toBeTruthy();
|
||
|
||
await waitForSearchCondition(queuedSearch.page, queuedSearch.searchId, (payload) => {
|
||
return payload.status === 'processing' || payload.status === 'completed';
|
||
}, SEARCH_COMPLETION_TIMEOUT_MS);
|
||
|
||
await Promise.all(pages.map((page) => waitForLegacyResult(page, '202')));
|
||
|
||
await Promise.all([
|
||
verifySearchLogs(searchOne.id, { expectedSelfieName: SELFIE_NAME.split(/[\\/]+/u).pop() }),
|
||
verifySearchLogs(searchTwo.id, { expectedSelfieName: 'DSC_1987.JPG' }),
|
||
verifySearchLogs(searchThree.id, { expectedSelfieName: 'DSC_2058.JPG' })
|
||
]);
|
||
} finally {
|
||
await closeContexts(contexts);
|
||
}
|
||
});
|