End to end tests
This commit is contained in:
parent
c71e4b4cd0
commit
fed82d1ae8
26 changed files with 1016 additions and 37 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue