End to end tests

This commit is contained in:
MaddoScientisto 2026-04-12 19:31:12 +02:00
commit fed82d1ae8
26 changed files with 1016 additions and 37 deletions

View file

@ -9,6 +9,7 @@ export const config = {
frontendUrl: process.env.FACEAI_FRONTEND_URL || 'http://localhost:5173',
publicBaseUrl: process.env.FACEAI_PUBLIC_BASE_URL || 'http://localhost:3001',
legacyReturnUrl: process.env.FACEAI_LEGACY_RETURN_URL || 'http://localhost:3001/dev/legacy/return',
legacyHomeUrl: process.env.FACEAI_LEGACY_HOME_URL || 'http://localhost:8080/index.jsp',
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
enableLocalLegacyStatic: process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC
? process.env.FACEAI_ENABLE_LOCAL_LEGACY_STATIC === '1'

View file

@ -100,12 +100,13 @@ export async function markSearchProcessing(redis, searchId, ttlSeconds = 24 * 60
}), ttlSeconds);
}
export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds) {
export async function markSearchCompleted(redis, searchId, resultId, matchCount, ttlSeconds, metadata = {}) {
return updateSearchRecord(redis, searchId, (current) => ({
...current,
status: 'completed',
resultId,
matchCount,
completionCode: metadata.completionCode || null,
completedAt: Date.now()
}), ttlSeconds);
}

View file

@ -68,7 +68,10 @@ function getFaceAiSession(req) {
function requireSession(req, res, next) {
const session = getFaceAiSession(req);
if (!session) {
res.status(401).json({ error: 'Not authenticated with FaceAI' });
res.status(401).json({
error: 'Not authenticated with FaceAI',
redirectUrl: config.legacyHomeUrl
});
return;
}
@ -446,6 +449,7 @@ app.get('/api/searches/:id', requireSession, async (req, res) => {
createdAt: search.createdAt,
completedAt: search.completedAt,
matchCount: search.matchCount || 0,
completionCode: search.completionCode || null,
errorCode: search.errorCode,
errorMessage: search.errorMessage
});

View file

@ -33,6 +33,7 @@ const copy = {
redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…',
processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…',
unavailableDefault: 'FaceAI non è disponibile per questa gara.',
noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.',
readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.',
completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.',
failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.',
@ -45,6 +46,7 @@ const copy = {
redirectError: 'Impossibile generare il link di ritorno.',
chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.',
raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.',
invalidRaceData: 'I dati della gara ricevuti non sono validi. Torna alla pagina gara e riapri Face ID dalla gara corretta.',
searchCreateError: 'Impossibile avviare la ricerca.',
faceAiAlt: 'FaceAI',
dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.'
@ -81,6 +83,7 @@ const copy = {
redirectLoading: 'Redirecting to the filtered legacy page…',
processingLoading: 'Biometric search in progress across all race photos…',
unavailableDefault: 'FaceAI is not available for this race.',
noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.',
readyMessage: 'Select a selfie to start a search limited to the current race.',
completedMessage: 'Search completed. Found {count} matching photos.',
failedMessage: 'The search did not complete. Check the message and try again.',
@ -93,6 +96,7 @@ const copy = {
redirectError: 'Unable to build the return link.',
chooseSelfie: 'Choose a selfie before starting the search.',
raceDataUnavailable: 'FaceAI data is not available for this race.',
invalidRaceData: 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.',
searchCreateError: 'Unable to start the search.',
faceAiAlt: 'FaceAI',
dropzoneDisabled: 'Upload is not available for this race.'
@ -111,6 +115,11 @@ const knownServerMessages = {
};
const simulatorUrl = 'http://localhost:8080/faceai_simulator.php?raceId=101&lang=it';
const legacyHomeUrl = 'http://localhost:8080/index.jsp';
function isInvalidRaceAvailability(availability) {
return availability?.reasonCode === 'RACE_DIRECTORY_NOT_FOUND' || availability?.reasonCode === 'MISSING_RACE_STORAGE';
}
export function useFaceAiHome() {
const session = ref(null);
@ -153,6 +162,14 @@ export function useFaceAiHome() {
return t(fallbackKey);
}
function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') {
if (isInvalidRaceAvailability(availability)) {
return t('invalidRaceData');
}
return localizeServerMessage(availability?.message, fallbackKey);
}
function shouldLogFaceAiDebug() {
return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
}
@ -176,6 +193,24 @@ export function useFaceAiHome() {
console.groupEnd();
}
function reportInvalidRaceAvailability(availability) {
if (!isInvalidRaceAvailability(availability)) {
return;
}
const details = {
raceId: session.value?.race?.id || null,
raceName: session.value?.race?.name || null,
lang: session.value?.lang || currentLocale.value,
reasonCode: availability.reasonCode,
message: availability.message,
storage: availability.storage || null,
raceDir: availability.raceDir || null
};
console.error(`[FaceAI] Invalid race data: ${JSON.stringify(details)}`);
}
const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing');
const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing');
const raceAvailability = computed(() => session.value?.availability || null);
@ -245,13 +280,17 @@ export function useFaceAiHome() {
const statusLabel = computed(() => {
if (!activeSearch.value) {
if (session.value && raceAvailability.value && !raceAvailability.value.available) {
return localizeServerMessage(raceAvailability.value.message, 'unavailableDefault');
return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault');
}
return t('readyMessage');
}
if (activeSearch.value.status === 'completed') {
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
return t('noFacesFoundMessage');
}
return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 });
}
@ -347,13 +386,21 @@ export function useFaceAiHome() {
const response = await fetch('/api/session', { credentials: 'include' });
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
loading.value = false;
logFaceAiDebug('Session load failed', { status: response.status });
logFaceAiDebug('Session load failed', { status: response.status, payload });
if (response.status === 401 || response.status === 403) {
window.location.replace(payload.redirectUrl || legacyHomeUrl);
}
return;
}
session.value = await response.json();
loading.value = false;
if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) {
errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData');
reportInvalidRaceAvailability(session.value.availability);
}
logFaceAiDebug('Session loaded');
}
@ -376,6 +423,14 @@ export function useFaceAiHome() {
if (activeSearch.value.status === 'completed') {
isSubmitting.value = false;
if (activeSearch.value.completionCode === 'NO_FACES_FOUND') {
isRedirecting.value = false;
redirectUrl.value = '';
clearSelectedFile();
logFaceAiDebug('Search completed without detectable faces', { searchId });
return;
}
const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' });
const payload = await redirectResponse.json();
if (!redirectResponse.ok) {
@ -408,7 +463,7 @@ export function useFaceAiHome() {
}
if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) {
errorMessage.value = localizeServerMessage(raceAvailability.value?.message, 'raceDataUnavailable');
errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable');
return;
}

View file

@ -1,9 +1,12 @@
import path from 'node:path';
export const config = {
redisUrl: process.env.FACEAI_REDIS_URL || 'redis://redis:6379',
queueName: process.env.FACEAI_QUEUE_NAME || 'faceai-searches',
workerConcurrency: Number(process.env.FACEAI_WORKER_CONCURRENCY || 2),
workerTimeoutMs: Number(process.env.FACEAI_WORKER_TIMEOUT_MS || 5 * 60 * 1000),
runtimeRoot: process.env.FACEAI_RUNTIME_ROOT || '/data/runtime',
logRoot: process.env.FACEAI_LOG_ROOT || path.join(process.env.FACEAI_RUNTIME_ROOT || '/data/runtime', 'logs'),
pklRoot: process.env.FACEAI_PKL_ROOT || '/data/pkl',
matcherBinary: process.env.FACEAI_MATCHER_BINARY || '/opt/face-recognition/face_matcher',
searchTtlSeconds: Number(process.env.FACEAI_SEARCH_TTL_SECONDS || 24 * 60 * 60),

View file

@ -15,6 +15,55 @@ import { parseMatcherCsv, resolvePklPath, runFaceMatcher } from './worker-utils.
const connection = createRedisConnection(config.redisUrl);
function formatLogLine(message, details) {
const timestamp = new Date().toISOString();
if (details === undefined) {
return `[${timestamp}] ${message}\n`;
}
return `[${timestamp}] ${message} ${JSON.stringify(details)}\n`;
}
async function appendSearchLog(logPath, message, details) {
await fs.mkdir(path.dirname(logPath), { recursive: true });
await fs.appendFile(logPath, formatLogLine(message, details), 'utf8');
}
async function resolveCompletionCode(logPath, matchCount) {
if (matchCount > 0) {
return null;
}
const matcherLog = await fs.readFile(logPath, 'utf8').catch(() => '');
if (/nessun\s+volt|no\s+faces?|no\s+face|0\s+faces?/i.test(matcherLog)) {
return 'NO_FACES_FOUND';
}
return 'NO_FACES_FOUND';
}
async function completeSearch(search, searchId, searchLogPath, matchCount, matches, completionCode) {
const result = await storeResultRecord(connection, {
raceId: search.raceId,
raceName: search.raceName,
userId: search.userId,
returnUrl: search.returnUrl,
lang: search.lang,
matches
}, config.resultTtlSeconds);
await appendSearchLog(searchLogPath, 'Completed FaceAI search', {
resultId: result.id,
matchCount,
completionCode
});
await markSearchCompleted(connection, searchId, result.id, matchCount, config.searchTtlSeconds, {
completionCode
});
await releaseActiveSearchLock(connection, search.userId, searchId);
}
async function processJob(job) {
const searchId = String(job.data.searchId || '');
const search = await getSearchRecord(connection, searchId);
@ -25,7 +74,20 @@ async function processJob(job) {
await markSearchProcessing(connection, searchId, config.searchTtlSeconds);
const searchDir = path.join(config.runtimeRoot, 'searches', searchId);
const searchLogDir = path.join(config.logRoot, 'searches', searchId);
const searchLogPath = path.join(searchLogDir, 'worker.log');
await fs.mkdir(searchDir, { recursive: true });
await fs.mkdir(searchLogDir, { recursive: true });
await appendSearchLog(searchLogPath, 'Starting FaceAI search', {
searchId,
raceId: search.raceId,
userId: search.userId,
selfiePath: search.selfiePath,
runtimeRoot: config.runtimeRoot,
logRoot: config.logRoot,
queueName: config.queueName
});
try {
const pklPath = await resolvePklPath({
@ -34,31 +96,51 @@ async function processJob(job) {
pklRoot: config.pklRoot
});
const csvPath = path.join(searchDir, 'result.csv');
const logPath = path.join(searchDir, 'matcher.log');
await runFaceMatcher({
matcherBinary: config.matcherBinary,
selfiePath: search.selfiePath,
await appendSearchLog(searchLogPath, 'Resolved PKL path', {
pklPath,
raceStorage: search.raceStorage
});
const csvPath = path.join(searchDir, 'result.csv');
const logPath = path.join(searchLogDir, 'matcher.log');
await appendSearchLog(searchLogPath, 'Running matcher', {
matcherBinary: config.matcherBinary,
csvPath,
logPath,
matcherLogPath: logPath,
timeoutMs: config.workerTimeoutMs
});
const matches = await parseMatcherCsv(csvPath);
const result = await storeResultRecord(connection, {
raceId: search.raceId,
raceName: search.raceName,
userId: search.userId,
returnUrl: search.returnUrl,
lang: search.lang,
matches
}, config.resultTtlSeconds);
try {
await runFaceMatcher({
matcherBinary: config.matcherBinary,
selfiePath: search.selfiePath,
pklPath,
csvPath,
logPath,
timeoutMs: config.workerTimeoutMs
});
} catch (error) {
if (error.message === 'face_matcher exited with code 1') {
await appendSearchLog(searchLogPath, 'Matcher reported no detectable faces', {
matcherLogPath: logPath,
selfiePath: search.selfiePath
});
await completeSearch(search, searchId, searchLogPath, 0, [], 'NO_FACES_FOUND');
return;
}
await markSearchCompleted(connection, searchId, result.id, matches.length, config.searchTtlSeconds);
await releaseActiveSearchLock(connection, search.userId, searchId);
throw error;
}
const matches = await parseMatcherCsv(csvPath);
const completionCode = await resolveCompletionCode(logPath, matches.length);
await completeSearch(search, searchId, searchLogPath, matches.length, matches, completionCode);
} catch (error) {
await appendSearchLog(searchLogPath, 'FaceAI search failed', {
message: error.message,
stack: error.stack || null
});
await markSearchFailed(connection, searchId, 'PROCESSOR_ERROR', error.message, config.searchTtlSeconds);
await releaseActiveSearchLock(connection, search.userId, searchId);
throw error;
@ -76,7 +158,7 @@ worker.on('completed', (job) => {
worker.on('failed', (job, error) => {
const searchId = job?.data?.searchId || 'unknown';
console.error(`Failed FaceAI search ${searchId}: ${error.message}`);
console.error(`Failed FaceAI search ${searchId}:`, error);
});
console.log(`FaceAI processor listening on queue ${config.queueName} with concurrency ${config.workerConcurrency}`);