diff --git a/backend/dist/src/index.js b/backend/dist/src/index.js
index 298bee3..ca88a8d 100644
--- a/backend/dist/src/index.js
+++ b/backend/dist/src/index.js
@@ -6,13 +6,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
const dotenv_1 = __importDefault(require("dotenv"));
const app_1 = require("./app");
dotenv_1.default.config();
-const port = Number(process.env.PORT ?? 3000);
+const port = Number(process.env.PORT ?? 3003);
const app = (0, app_1.buildApp)();
const start = async () => {
+ const host = '0.0.0.0';
try {
- await app.listen({ port, host: '0.0.0.0' });
- // eslint-disable-next-line no-console
- console.log(`Server listening on http://0.0.0.0:${port}`);
+ await app.listen({ port, host });
+ console.log(`🚀 Server ready and listening on http://${host}:${port}`);
}
catch (err) {
app.log.error(err);
diff --git a/backend/dist/src/routes/games.js b/backend/dist/src/routes/games.js
index 43cb389..aaea9be 100644
--- a/backend/dist/src/routes/games.js
+++ b/backend/dist/src/routes/games.js
@@ -3,6 +3,29 @@ Object.defineProperty(exports, "__esModule", { value: true });
const gamesController_1 = require("../controllers/gamesController");
const gameValidator_1 = require("../validators/gameValidator");
const zod_1 = require("zod");
+// Esquema de validación para crear juego desde metadatos
+const createGameFromMetadataSchema = zod_1.z.object({
+ metadata: zod_1.z.object({
+ source: zod_1.z.string(),
+ externalIds: zod_1.z.record(zod_1.z.number()).optional(),
+ name: zod_1.z.string().min(1, 'El nombre es requerido'),
+ slug: zod_1.z.string().optional(),
+ releaseDate: zod_1.z.string().optional(),
+ genres: zod_1.z.array(zod_1.z.string()).optional(),
+ coverUrl: zod_1.z.string().optional(),
+ }),
+ overrides: zod_1.z
+ .object({
+ platformId: zod_1.z.string().optional(),
+ description: zod_1.z.string().optional(),
+ priceCents: zod_1.z.number().optional(),
+ currency: zod_1.z.string().optional(),
+ store: zod_1.z.string().optional(),
+ date: zod_1.z.string().optional(),
+ condition: gameValidator_1.GameCondition,
+ })
+ .optional(),
+});
async function gamesRoutes(app) {
/**
* GET /api/games
@@ -95,6 +118,46 @@ async function gamesRoutes(app) {
throw error;
}
});
+ /**
+ * POST /api/games/from-metadata
+ * Crear un juego a partir de metadatos de búsqueda
+ */
+ app.post('/games/from-metadata', async (request, reply) => {
+ try {
+ // Validar entrada con Zod
+ const validated = createGameFromMetadataSchema.parse(request.body);
+ const { metadata, overrides } = validated;
+ // Obtener el ID externo principal basado en la fuente
+ const sourceId = metadata.externalIds?.[metadata.source]
+ ? String(metadata.externalIds[metadata.source])
+ : undefined;
+ // Mapear metadatos a estructura de CreateGameInput
+ const gameInput = {
+ title: metadata.name,
+ description: overrides?.description,
+ priceCents: overrides?.priceCents,
+ currency: overrides?.currency || 'USD',
+ store: overrides?.store,
+ date: overrides?.date,
+ condition: overrides?.condition,
+ platformId: overrides?.platformId,
+ source: metadata.source,
+ sourceId,
+ };
+ // Crear el juego usando GamesController
+ const game = await gamesController_1.GamesController.createGame(gameInput);
+ return reply.code(201).send(game);
+ }
+ catch (error) {
+ if (error instanceof zod_1.ZodError) {
+ return reply.code(400).send({
+ error: 'Validación fallida',
+ details: error.errors,
+ });
+ }
+ throw error;
+ }
+ });
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
diff --git a/backend/dist/src/routes/metadata.js b/backend/dist/src/routes/metadata.js
index b374525..71f94da 100644
--- a/backend/dist/src/routes/metadata.js
+++ b/backend/dist/src/routes/metadata.js
@@ -40,23 +40,25 @@ const zod_2 = require("zod");
const searchMetadataSchema = zod_1.z.object({
q: zod_1.z.string().min(1, 'El parámetro de búsqueda es requerido'),
platform: zod_1.z.string().optional(),
+ year: zod_1.z.coerce.number().int().min(1900).max(2100).optional(),
});
async function metadataRoutes(app) {
/**
- * GET /api/metadata/search?q=query&platform=optional
- * Buscar metadata de juegos
+ * GET /api/metadata/search?q=query&platform=optional&year=optional
+ * Buscar metadata de juegos en múltiples fuentes (IGDB, RAWG, TheGamesDB)
*/
app.get('/metadata/search', async (request, reply) => {
try {
// Validar parámetros de query con Zod
const validated = searchMetadataSchema.parse(request.query);
- // Llamar a metadataService
- const result = await metadataService.enrichGame({
+ // Llamar a metadataService.searchGames para obtener múltiples resultados
+ const results = await metadataService.searchGames({
title: validated.q,
platform: validated.platform,
+ year: validated.year,
});
- // Si hay resultado, devolver como array; si no, devolver array vacío
- return reply.code(200).send(result ? [result] : []);
+ // Devolver array de resultados
+ return reply.code(200).send(results);
}
catch (error) {
if (error instanceof zod_2.ZodError) {
diff --git a/backend/dist/src/services/archiveReader.js b/backend/dist/src/services/archiveReader.js
index aecf1bf..e17fc69 100644
--- a/backend/dist/src/services/archiveReader.js
+++ b/backend/dist/src/services/archiveReader.js
@@ -114,7 +114,12 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) {
}
catch (e) { }
}
- resolve(proc.stdout);
+ if (proc.stdout) {
+ resolve(proc.stdout);
+ }
+ else {
+ resolve(null);
+ }
};
proc.once('error', onProcError);
if (proc.stdout && proc.stdout.once) {
@@ -152,7 +157,11 @@ async function streamArchiveEntry(filePath, entryPath, logger = console) {
// Fallback for zip
if (ext === 'zip') {
try {
- const proc2 = (0, child_process_1.spawn)('unzip', ['-p', filePath, entryPath]);
+ const proc2 = (0, child_process_1.spawn)('unzip', [
+ '-p',
+ filePath,
+ entryPath,
+ ]);
const stream2 = await waitForStreamOrError(proc2);
if (stream2)
return stream2;
diff --git a/backend/dist/src/services/datVerifier.js b/backend/dist/src/services/datVerifier.js
index b08d262..5f79486 100644
--- a/backend/dist/src/services/datVerifier.js
+++ b/backend/dist/src/services/datVerifier.js
@@ -9,7 +9,7 @@ function ensureArray(v) {
return Array.isArray(v) ? v : [v];
}
function normalizeHex(v) {
- if (!v)
+ if (!v || typeof v !== 'string')
return undefined;
return v.trim().toLowerCase();
}
@@ -20,7 +20,8 @@ function parseDat(xml) {
trimValues: true,
});
const parsed = parser.parse(xml);
- const datafile = parsed?.datafile ?? parsed;
+ const datafile = parsed?.datafile ??
+ parsed;
const rawGames = ensureArray(datafile?.game);
const games = rawGames.map((g) => {
// game name may be an attribute or a child node
diff --git a/backend/dist/src/services/igdbClient.js b/backend/dist/src/services/igdbClient.js
index 528f672..ddb2984 100644
--- a/backend/dist/src/services/igdbClient.js
+++ b/backend/dist/src/services/igdbClient.js
@@ -27,7 +27,7 @@ async function getToken() {
const res = await (0, undici_1.fetch)(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
if (!res.ok)
return null;
- const json = await res.json();
+ const json = (await res.json());
const token = json.access_token;
const expires = Number(json.expires_in) || 0;
if (!token)
@@ -42,6 +42,19 @@ async function getToken() {
}
}
function mapIgdbHit(r) {
+ const platforms = Array.isArray(r.platforms)
+ ? r.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ abbreviation: p.abbreviation,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
+ const genres = Array.isArray(r.genres)
+ ? r.genres.map((g) => g.name)
+ : undefined;
+ // IGDB devuelve URLs relativas (//images.igdb.com/...), agregar protocolo https:
+ const coverUrl = r.cover?.url ? `https:${r.cover.url}` : undefined;
return {
id: r.id,
name: r.name,
@@ -49,9 +62,9 @@ function mapIgdbHit(r) {
releaseDate: r.first_release_date
? new Date(r.first_release_date * 1000).toISOString()
: undefined,
- genres: Array.isArray(r.genres) ? r.genres : undefined,
- platforms: Array.isArray(r.platforms) ? r.platforms : undefined,
- coverUrl: r.cover?.url ?? undefined,
+ genres,
+ platforms,
+ coverUrl,
source: 'igdb',
};
}
@@ -66,7 +79,7 @@ async function searchGames(query, _platform) {
Accept: 'application/json',
'Content-Type': 'text/plain',
};
- const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
+ const body = `search "${query}"; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 10;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
@@ -93,7 +106,7 @@ async function getGameById(id) {
Accept: 'application/json',
'Content-Type': 'text/plain',
};
- const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
+ const body = `where id = ${id}; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 1;`;
try {
const res = await (0, undici_1.fetch)(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok)
diff --git a/backend/dist/src/services/importService.js b/backend/dist/src/services/importService.js
index 9245e1f..d1fe0c6 100644
--- a/backend/dist/src/services/importService.js
+++ b/backend/dist/src/services/importService.js
@@ -67,7 +67,7 @@ async function importDirectory(options, logger = console) {
processed++;
try {
let hashes;
- if (file.isArchiveEntry) {
+ if (file.isArchiveEntry && file.containerPath && file.entryPath) {
const stream = await (0, archiveReader_1.streamArchiveEntry)(file.containerPath, file.entryPath, logger);
if (!stream) {
logger.warn?.({ file }, 'importDirectory: no se pudo extraer entrada del archive, saltando');
diff --git a/backend/dist/src/services/metadataService.js b/backend/dist/src/services/metadataService.js
index 8099359..051e606 100644
--- a/backend/dist/src/services/metadataService.js
+++ b/backend/dist/src/services/metadataService.js
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.enrichGame = enrichGame;
+exports.searchGames = searchGames;
/**
* metadataService
* - `enrichGame({ title, platform? })` -> intenta IGDB, RAWG, TheGamesDB
@@ -43,13 +44,15 @@ const rawg = __importStar(require("./rawgClient"));
const thegamesdb = __importStar(require("./thegamesdbClient"));
function normalize(hit) {
const base = {
- source: hit.source ?? 'unknown',
+ source: hit.source ?? 'igdb',
externalIds: {},
+ name: hit.name,
title: hit.name,
- slug: hit.slug,
- releaseDate: hit.releaseDate,
- genres: hit.genres,
- coverUrl: hit.coverUrl,
+ slug: hit.slug ?? undefined,
+ releaseDate: hit.releaseDate ?? null,
+ genres: hit.genres ?? undefined,
+ coverUrl: hit.coverUrl ?? undefined,
+ platforms: hit.platforms ?? undefined,
};
if (hit.source === 'igdb' && typeof hit.id === 'number')
base.externalIds.igdb = hit.id;
@@ -90,7 +93,101 @@ async function enrichGame(opts) {
}
return null;
}
-exports.default = { enrichGame };
+/**
+ * Normaliza un nombre para deduplicación
+ * Convierte a lowercase y remueve caracteres especiales
+ */
+function normalizeName(name) {
+ return name
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .trim();
+}
+/**
+ * Prioridad de fuentes para deduplicación
+ */
+const SOURCE_PRIORITY = {
+ igdb: 1,
+ rawg: 2,
+ thegamesdb: 3,
+};
+/**
+ * Busca juegos en paralelo en IGDB, RAWG y TheGamesDB
+ * Devuelve múltiples resultados con deduplicación
+ */
+async function searchGames(opts) {
+ const { title, platform, year } = opts;
+ if (!title)
+ return [];
+ // Ejecutar búsquedas en paralelo
+ const [igdbHits, rawgHits, tgdbHits] = await Promise.allSettled([
+ igdb.searchGames(title, platform),
+ rawg.searchGames(title),
+ thegamesdb.searchGames(title),
+ ]);
+ // Extraer resultados de las promesas resueltas
+ const allResults = [];
+ if (igdbHits.status === 'fulfilled') {
+ allResults.push(...igdbHits.value.map(normalize));
+ }
+ if (rawgHits.status === 'fulfilled') {
+ allResults.push(...rawgHits.value.map(normalize));
+ }
+ if (tgdbHits.status === 'fulfilled') {
+ allResults.push(...tgdbHits.value.map(normalize));
+ }
+ // Filtrar por año si se proporciona
+ let filteredResults = allResults;
+ if (year) {
+ filteredResults = allResults.filter((result) => {
+ if (!result.releaseDate)
+ return false;
+ const resultYear = new Date(result.releaseDate).getFullYear();
+ return resultYear === year;
+ });
+ }
+ // Deduplicar por nombre normalizado
+ const deduplicatedMap = new Map();
+ for (const result of filteredResults) {
+ const normalizedName = normalizeName(result.name);
+ const existing = deduplicatedMap.get(normalizedName);
+ if (!existing) {
+ // Primer resultado con este nombre
+ deduplicatedMap.set(normalizedName, result);
+ }
+ else {
+ // Ya existe, fusionar externalIds y mantener el de mayor prioridad
+ const existingPriority = SOURCE_PRIORITY[existing.source] ?? 999;
+ const currentPriority = SOURCE_PRIORITY[result.source] ?? 999;
+ if (currentPriority < existingPriority) {
+ // El resultado actual tiene mayor prioridad
+ // Fusionar externalIds del existente
+ result.externalIds = {
+ ...result.externalIds,
+ ...existing.externalIds,
+ };
+ deduplicatedMap.set(normalizedName, result);
+ }
+ else {
+ // Mantener el existente y fusionar externalIds del actual
+ existing.externalIds = {
+ ...existing.externalIds,
+ ...result.externalIds,
+ };
+ }
+ }
+ }
+ // Convertir el mapa a array y ordenar por prioridad de fuente
+ const deduplicatedResults = Array.from(deduplicatedMap.values()).sort((a, b) => {
+ const priorityA = SOURCE_PRIORITY[a.source] ?? 999;
+ const priorityB = SOURCE_PRIORITY[b.source] ?? 999;
+ return priorityA - priorityB;
+ });
+ // Limitar a 50 resultados totales
+ return deduplicatedResults.slice(0, 50);
+}
+exports.default = { enrichGame, searchGames };
/**
* Metadatos:
* Autor: GitHub Copilot
diff --git a/backend/dist/src/services/rawgClient.js b/backend/dist/src/services/rawgClient.js
index 527d21b..60c0441 100644
--- a/backend/dist/src/services/rawgClient.js
+++ b/backend/dist/src/services/rawgClient.js
@@ -18,18 +18,30 @@ async function searchGames(query) {
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return [];
- const json = await res.json();
+ const json = (await res.json());
const hits = Array.isArray(json.results) ? json.results : [];
- return hits.map((r) => ({
- id: r.id,
- name: r.name,
- slug: r.slug,
- releaseDate: r.released,
- genres: Array.isArray(r.genres) ? r.genres.map((g) => g.name) : undefined,
- platforms: r.platforms,
- coverUrl: r.background_image ?? undefined,
- source: 'rawg',
- }));
+ return hits.map((r) => {
+ const platforms = Array.isArray(r.platforms)
+ ? r.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
+ const genres = Array.isArray(r.genres)
+ ? r.genres.map((g) => g.name)
+ : undefined;
+ return {
+ id: r.id,
+ name: r.name,
+ slug: r.slug,
+ releaseDate: r.released,
+ genres,
+ platforms,
+ coverUrl: r.background_image ?? undefined,
+ source: 'rawg',
+ };
+ });
}
catch (err) {
// eslint-disable-next-line no-console
@@ -46,15 +58,26 @@ async function getGameById(id) {
const res = await (0, undici_1.fetch)(url);
if (!res.ok)
return null;
- const json = await res.json();
+ const json = (await res.json());
if (!json)
return null;
+ const platforms = Array.isArray(json.platforms)
+ ? json.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
+ const genres = Array.isArray(json.genres)
+ ? json.genres.map((g) => g.name)
+ : undefined;
return {
id: json.id,
name: json.name,
slug: json.slug,
releaseDate: json.released,
- genres: Array.isArray(json.genres) ? json.genres.map((g) => g.name) : undefined,
+ genres,
+ platforms,
coverUrl: json.background_image ?? undefined,
source: 'rawg',
};
diff --git a/backend/dist/src/services/thegamesdbClient.js b/backend/dist/src/services/thegamesdbClient.js
index 6ea5d89..e0c9a41 100644
--- a/backend/dist/src/services/thegamesdbClient.js
+++ b/backend/dist/src/services/thegamesdbClient.js
@@ -18,18 +18,30 @@ async function searchGames(query) {
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return [];
- const json = await res.json();
+ const json = (await res.json());
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const hits = [];
for (const gid of Object.keys(games)) {
const g = games[gid];
+ const genres = Array.isArray(g?.game?.genres)
+ ? g.game.genres.map((x) => x.name)
+ : undefined;
+ const platforms = Array.isArray(g?.game?.platforms)
+ ? g.game.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ abbreviation: p.abbreviation,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
hits.push({
id: Number(gid),
name: g?.game?.title ?? g?.title ?? String(gid),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
- genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined,
+ genres,
+ platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
@@ -53,19 +65,31 @@ async function getGameById(id) {
const res = await (0, undici_1.fetch)(url, { headers: { 'Api-Key': key } });
if (!res.ok)
return null;
- const json = await res.json();
+ const json = (await res.json());
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const firstKey = Object.keys(games)[0];
const g = games[firstKey];
if (!g)
return null;
+ const genres = Array.isArray(g?.game?.genres)
+ ? g.game.genres.map((x) => x.name)
+ : undefined;
+ const platforms = Array.isArray(g?.game?.platforms)
+ ? g.game.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ abbreviation: p.abbreviation,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
return {
id: Number(firstKey),
name: g?.game?.title ?? g?.title ?? String(firstKey),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
- genres: Array.isArray(g?.game?.genres) ? g.game.genres.map((x) => x.name) : undefined,
+ genres,
+ platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
diff --git a/backend/dist/src/types/index.js b/backend/dist/src/types/index.js
new file mode 100644
index 0000000..e32f6cb
--- /dev/null
+++ b/backend/dist/src/types/index.js
@@ -0,0 +1,7 @@
+"use strict";
+/**
+ * Tipos compartidos del backend
+ * Autor: GitHub Copilot
+ * Última actualización: 2026-03-18
+ */
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/backend/dist/tests/routes/games.spec.js b/backend/dist/tests/routes/games.spec.js
index 70f38c9..3599139 100644
--- a/backend/dist/tests/routes/games.spec.js
+++ b/backend/dist/tests/routes/games.spec.js
@@ -220,6 +220,132 @@ const prisma_1 = require("../../src/plugins/prisma");
(0, vitest_1.expect)(res.statusCode).toBe(404);
});
});
+ (0, vitest_1.describe)('POST /api/games/from-metadata', () => {
+ (0, vitest_1.it)('debería crear un juego a partir de metadatos', async () => {
+ const payload = {
+ metadata: {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ },
+ };
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(201);
+ const body = res.json();
+ (0, vitest_1.expect)(body).toHaveProperty('id');
+ (0, vitest_1.expect)(body.title).toBe('Super Mario Bros.');
+ (0, vitest_1.expect)(body.source).toBe('igdb');
+ (0, vitest_1.expect)(body.sourceId).toBe('1234');
+ });
+ (0, vitest_1.it)('debería crear un juego con overrides', async () => {
+ const platform = await prisma_1.prisma.platform.create({
+ data: { name: 'Nintendo Entertainment System', slug: 'nes' },
+ });
+ const payload = {
+ metadata: {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ },
+ overrides: {
+ platformId: platform.id,
+ description: 'Descripción personalizada',
+ priceCents: 10000,
+ currency: 'USD',
+ store: 'eBay',
+ date: '2025-01-15',
+ condition: 'CIB',
+ },
+ };
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(201);
+ const body = res.json();
+ (0, vitest_1.expect)(body.title).toBe('Super Mario Bros.');
+ (0, vitest_1.expect)(body.description).toBe('Descripción personalizada');
+ (0, vitest_1.expect)(body.gamePlatforms).toHaveLength(1);
+ (0, vitest_1.expect)(body.gamePlatforms[0].platformId).toBe(platform.id);
+ (0, vitest_1.expect)(body.purchases).toHaveLength(1);
+ (0, vitest_1.expect)(body.purchases[0].priceCents).toBe(10000);
+ });
+ (0, vitest_1.it)('debería devolver 400 si falta el campo metadata', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload: {},
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(400);
+ });
+ (0, vitest_1.it)('debería devolver 400 si metadata.name está vacío', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload: {
+ metadata: {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: '',
+ slug: 'super-mario-bros',
+ },
+ },
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(400);
+ });
+ (0, vitest_1.it)('debería usar el externalId principal como sourceId', async () => {
+ const payload = {
+ metadata: {
+ source: 'rawg',
+ externalIds: { rawg: 5678, igdb: 1234 },
+ name: 'Zelda',
+ slug: 'zelda',
+ },
+ };
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(201);
+ const body = res.json();
+ (0, vitest_1.expect)(body.source).toBe('rawg');
+ (0, vitest_1.expect)(body.sourceId).toBe('5678');
+ });
+ (0, vitest_1.it)('debería manejar metadata sin externalIds', async () => {
+ const payload = {
+ metadata: {
+ source: 'manual',
+ externalIds: {},
+ name: 'Custom Game',
+ slug: 'custom-game',
+ },
+ };
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(201);
+ const body = res.json();
+ (0, vitest_1.expect)(body.title).toBe('Custom Game');
+ (0, vitest_1.expect)(body.source).toBe('manual');
+ (0, vitest_1.expect)(body.sourceId).toBeNull();
+ });
+ });
});
/**
* Metadatos:
diff --git a/backend/dist/tests/routes/metadata.spec.js b/backend/dist/tests/routes/metadata.spec.js
index deb5dc8..5e92e5c 100644
--- a/backend/dist/tests/routes/metadata.spec.js
+++ b/backend/dist/tests/routes/metadata.spec.js
@@ -47,19 +47,32 @@ const metadataService = __importStar(require("../../src/services/metadataService
vitest_1.vi.restoreAllMocks();
});
(0, vitest_1.describe)('GET /api/metadata/search', () => {
- (0, vitest_1.it)('debería devolver resultados cuando se busca un juego existente', async () => {
+ (0, vitest_1.it)('debería devolver múltiples resultados cuando se busca un juego existente', async () => {
const mockResults = [
{
source: 'igdb',
externalIds: { igdb: 1 },
+ name: 'The Legend of Zelda',
title: 'The Legend of Zelda',
slug: 'the-legend-of-zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover.jpg',
+ platforms: undefined,
+ },
+ {
+ source: 'rawg',
+ externalIds: { rawg: 2 },
+ name: 'The Legend of Zelda: A Link to the Past',
+ title: 'The Legend of Zelda: A Link to the Past',
+ slug: 'the-legend-of-zelda-a-link-to-the-past',
+ releaseDate: '1991-11-21',
+ genres: ['Adventure'],
+ coverUrl: 'https://example.com/cover2.jpg',
+ platforms: undefined,
},
];
- vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
+ vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue(mockResults);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=zelda',
@@ -68,10 +81,10 @@ const metadataService = __importStar(require("../../src/services/metadataService
const body = res.json();
(0, vitest_1.expect)(Array.isArray(body)).toBe(true);
(0, vitest_1.expect)(body.length).toBeGreaterThan(0);
- (0, vitest_1.expect)(body[0].title).toContain('Zelda');
+ (0, vitest_1.expect)(body[0].name).toContain('Zelda');
});
(0, vitest_1.it)('debería devolver lista vacía cuando no hay resultados', async () => {
- vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
+ vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=nonexistentgame12345',
@@ -96,16 +109,43 @@ const metadataService = __importStar(require("../../src/services/metadataService
});
(0, vitest_1.expect)(res.statusCode).toBe(400);
});
- (0, vitest_1.it)('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
- const enrichSpy = vitest_1.vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
+ (0, vitest_1.it)('debería pasar el parámetro platform a searchGames si se proporciona', async () => {
+ const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
url: '/api/metadata/search?q=mario&platform=Nintendo%2064',
});
(0, vitest_1.expect)(res.statusCode).toBe(200);
- (0, vitest_1.expect)(enrichSpy).toHaveBeenCalledWith({
+ (0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'Nintendo 64',
+ year: undefined,
+ });
+ });
+ (0, vitest_1.it)('debería pasar el parámetro year a searchGames si se proporciona', async () => {
+ const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/metadata/search?q=mario&year=1990',
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
+ (0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({
+ title: 'mario',
+ platform: undefined,
+ year: 1990,
+ });
+ });
+ (0, vitest_1.it)('debería pasar todos los parámetros a searchGames', async () => {
+ const searchSpy = vitest_1.vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/metadata/search?q=mario&platform=NES&year=1985',
+ });
+ (0, vitest_1.expect)(res.statusCode).toBe(200);
+ (0, vitest_1.expect)(searchSpy).toHaveBeenCalledWith({
+ title: 'mario',
+ platform: 'NES',
+ year: 1985,
});
});
});
diff --git a/backend/dist/tests/services/fsScanner.archiveEntries.spec.js b/backend/dist/tests/services/fsScanner.archiveEntries.spec.js
index 0acf956..ffdff8e 100644
--- a/backend/dist/tests/services/fsScanner.archiveEntries.spec.js
+++ b/backend/dist/tests/services/fsScanner.archiveEntries.spec.js
@@ -10,7 +10,9 @@ const vitest_1 = require("vitest");
vitest_1.vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vitest_1.vi.fn() }));
const fsScanner_1 = __importDefault(require("../../src/services/fsScanner"));
const archiveReader_1 = require("../../src/services/archiveReader");
-(0, vitest_1.afterEach)(() => vitest_1.vi.restoreAllMocks());
+(0, vitest_1.afterEach)(() => {
+ vitest_1.vi.restoreAllMocks();
+});
(0, vitest_1.it)('expone entradas internas de archivos como items virtuales', async () => {
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'fsScanner-test-'));
const collectionFile = path_1.default.join(tmpDir, 'collection.zip');
@@ -22,11 +24,13 @@ const archiveReader_1 = require("../../src/services/archiveReader");
const expectedPath = `${collectionFile}::inner/rom1.bin`;
const found = results.find((r) => r.path === expectedPath);
(0, vitest_1.expect)(found).toBeDefined();
- (0, vitest_1.expect)(found.isArchiveEntry).toBe(true);
- (0, vitest_1.expect)(found.containerPath).toBe(collectionFile);
- (0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin');
- (0, vitest_1.expect)(found.filename).toBe('rom1.bin');
- (0, vitest_1.expect)(found.format).toBe('bin');
+ if (found) {
+ (0, vitest_1.expect)(found.isArchiveEntry).toBe(true);
+ (0, vitest_1.expect)(found.containerPath).toBe(collectionFile);
+ (0, vitest_1.expect)(found.entryPath).toBe('inner/rom1.bin');
+ (0, vitest_1.expect)(found.filename).toBe('rom1.bin');
+ (0, vitest_1.expect)(found.format).toBe('bin');
+ }
await fs_1.promises.rm(tmpDir, { recursive: true, force: true });
});
(0, vitest_1.it)('ignora entradas con traversal o paths absolutos', async () => {
diff --git a/backend/dist/tests/services/importService.archiveEntry.spec.js b/backend/dist/tests/services/importService.archiveEntry.spec.js
index 9645e4d..7c8985f 100644
--- a/backend/dist/tests/services/importService.archiveEntry.spec.js
+++ b/backend/dist/tests/services/importService.archiveEntry.spec.js
@@ -17,11 +17,16 @@ const fsScanner_1 = require("../../src/services/fsScanner");
const archiveReader_1 = require("../../src/services/archiveReader");
const prisma_1 = __importDefault(require("../../src/plugins/prisma"));
const crypto_1 = require("crypto");
+// Mock Date.now() para timestamps consistentes en tests
+const FIXED_TIMESTAMP = 1234567890123;
+const dateNowSpy = vitest_1.vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.restoreAllMocks();
+ dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
(0, vitest_1.describe)('services/importService (archive entries)', () => {
(0, vitest_1.it)('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
+ const data = Buffer.from('import-archive-test');
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
@@ -29,12 +34,11 @@ const crypto_1 = require("crypto");
entryPath: 'inner/rom1.bin',
filename: 'rom1.bin',
name: 'inner/rom1.bin',
- size: 123,
+ size: data.length,
format: 'bin',
isArchiveEntry: true,
},
];
- const data = Buffer.from('import-archive-test');
fsScanner_1.scanDirectory.mockResolvedValue(files);
archiveReader_1.streamArchiveEntry.mockResolvedValue(stream_1.Readable.from([data]));
prisma_1.default.game.findFirst.mockResolvedValue(null);
@@ -53,12 +57,12 @@ const crypto_1 = require("crypto");
});
(0, vitest_1.expect)(prisma_1.default.game.create.mock.calls[0][0]).toEqual({
data: {
- title: 'ROM1',
- slug: 'rom1-1234567890123',
+ title: 'rom1',
+ slug: vitest_1.expect.stringMatching(/^rom1-\d+$/),
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
- romSize: 123,
+ romSize: data.length,
romChecksum: md5,
romFormat: 'bin',
romHashes: vitest_1.expect.any(String),
diff --git a/backend/dist/tests/services/importService.spec.js b/backend/dist/tests/services/importService.spec.js
index 3bdc958..4992215 100644
--- a/backend/dist/tests/services/importService.spec.js
+++ b/backend/dist/tests/services/importService.spec.js
@@ -19,9 +19,13 @@ const importService_1 = require("../../src/services/importService");
const fsScanner_1 = require("../../src/services/fsScanner");
const checksumService_1 = require("../../src/services/checksumService");
const prisma_1 = __importDefault(require("../../src/plugins/prisma"));
+// Mock Date.now() para timestamps consistentes en tests
+const FIXED_TIMESTAMP = 1234567890123;
+const dateNowSpy = vitest_1.vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
(0, vitest_1.describe)('services/importService', () => {
(0, vitest_1.beforeEach)(() => {
vitest_1.vi.clearAllMocks();
+ dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
(0, vitest_1.it)('exporta createSlug e importDirectory', () => {
(0, vitest_1.expect)(typeof importService_1.createSlug).toBe('function');
diff --git a/backend/dist/tests/services/metadataService.spec.js b/backend/dist/tests/services/metadataService.spec.js
index f2fa166..f3e20e6 100644
--- a/backend/dist/tests/services/metadataService.spec.js
+++ b/backend/dist/tests/services/metadataService.spec.js
@@ -100,4 +100,182 @@ const metadataService_1 = require("../../src/services/metadataService");
const res = await (0, metadataService_1.enrichGame)({ title: 'Juego inexistente' });
(0, vitest_1.expect)(res).toBeNull();
});
+ (0, vitest_1.describe)('searchGames', () => {
+ (0, vitest_1.it)('debería buscar en paralelo en IGDB, RAWG y TheGamesDB', async () => {
+ igdb.searchGames.mockResolvedValue([
+ {
+ id: 1,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/cover.jpg',
+ source: 'igdb',
+ },
+ ]);
+ rawg.searchGames.mockResolvedValue([
+ {
+ id: 2,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros-rawg',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://rawg.com/cover.jpg',
+ source: 'rawg',
+ },
+ ]);
+ tgdb.searchGames.mockResolvedValue([
+ {
+ id: 3,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros-tgdb',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://tgdb.com/cover.jpg',
+ source: 'thegamesdb',
+ },
+ ]);
+ const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario Bros.' });
+ (0, vitest_1.expect)(results.length).toBeGreaterThan(0);
+ (0, vitest_1.expect)(igdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.', undefined);
+ (0, vitest_1.expect)(rawg.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
+ (0, vitest_1.expect)(tgdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
+ });
+ (0, vitest_1.it)('debería deduplicar resultados por nombre normalizado', async () => {
+ igdb.searchGames.mockResolvedValue([
+ {
+ id: 1,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/cover.jpg',
+ source: 'igdb',
+ },
+ ]);
+ rawg.searchGames.mockResolvedValue([
+ {
+ id: 2,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros-rawg',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://rawg.com/cover.jpg',
+ source: 'rawg',
+ },
+ ]);
+ tgdb.searchGames.mockResolvedValue([]);
+ const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario Bros.' });
+ // Debería haber un solo resultado (prioridad IGDB)
+ (0, vitest_1.expect)(results.length).toBe(1);
+ (0, vitest_1.expect)(results[0].source).toBe('igdb');
+ (0, vitest_1.expect)(results[0].externalIds.igdb).toBe(1);
+ (0, vitest_1.expect)(results[0].externalIds.rawg).toBe(2);
+ });
+ (0, vitest_1.it)('debería priorizar IGDB > RAWG > TheGamesDB en deduplicación', async () => {
+ igdb.searchGames.mockResolvedValue([
+ {
+ id: 1,
+ name: 'Zelda',
+ slug: 'zelda',
+ releaseDate: '1986-02-21',
+ genres: ['Adventure'],
+ coverUrl: 'http://igdb.com/zelda.jpg',
+ source: 'igdb',
+ },
+ ]);
+ rawg.searchGames.mockResolvedValue([
+ {
+ id: 2,
+ name: 'Zelda',
+ slug: 'zelda-rawg',
+ releaseDate: '1986-02-21',
+ genres: ['Adventure'],
+ coverUrl: 'http://rawg.com/zelda.jpg',
+ source: 'rawg',
+ },
+ ]);
+ tgdb.searchGames.mockResolvedValue([
+ {
+ id: 3,
+ name: 'Zelda',
+ slug: 'zelda-tgdb',
+ releaseDate: '1986-02-21',
+ genres: ['Adventure'],
+ coverUrl: 'http://tgdb.com/zelda.jpg',
+ source: 'thegamesdb',
+ },
+ ]);
+ const results = await (0, metadataService_1.searchGames)({ title: 'Zelda' });
+ (0, vitest_1.expect)(results.length).toBe(1);
+ (0, vitest_1.expect)(results[0].source).toBe('igdb');
+ (0, vitest_1.expect)(results[0].externalIds).toEqual({
+ igdb: 1,
+ rawg: 2,
+ thegamesdb: 3,
+ });
+ });
+ (0, vitest_1.it)('debería devolver array vacío si no hay resultados', async () => {
+ igdb.searchGames.mockResolvedValue([]);
+ rawg.searchGames.mockResolvedValue([]);
+ tgdb.searchGames.mockResolvedValue([]);
+ const results = await (0, metadataService_1.searchGames)({ title: 'Juego inexistente' });
+ (0, vitest_1.expect)(results).toEqual([]);
+ });
+ (0, vitest_1.it)('debería manejar errores de API y continuar con otras fuentes', async () => {
+ igdb.searchGames.mockRejectedValue(new Error('IGDB error'));
+ rawg.searchGames.mockResolvedValue([
+ {
+ id: 2,
+ name: 'Sonic',
+ slug: 'sonic',
+ releaseDate: '1991-06-23',
+ genres: ['Platform'],
+ coverUrl: 'http://rawg.com/sonic.jpg',
+ source: 'rawg',
+ },
+ ]);
+ tgdb.searchGames.mockResolvedValue([]);
+ const results = await (0, metadataService_1.searchGames)({ title: 'Sonic' });
+ (0, vitest_1.expect)(results.length).toBe(1);
+ (0, vitest_1.expect)(results[0].source).toBe('rawg');
+ });
+ (0, vitest_1.it)('debería pasar el parámetro platform a IGDB', async () => {
+ igdb.searchGames.mockResolvedValue([]);
+ rawg.searchGames.mockResolvedValue([]);
+ tgdb.searchGames.mockResolvedValue([]);
+ await (0, metadataService_1.searchGames)({ title: 'Mario', platform: 'NES' });
+ (0, vitest_1.expect)(igdb.searchGames).toHaveBeenCalledWith('Mario', 'NES');
+ (0, vitest_1.expect)(rawg.searchGames).toHaveBeenCalledWith('Mario');
+ (0, vitest_1.expect)(tgdb.searchGames).toHaveBeenCalledWith('Mario');
+ });
+ (0, vitest_1.it)('debería mantener múltiples resultados con nombres diferentes', async () => {
+ igdb.searchGames.mockResolvedValue([
+ {
+ id: 1,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/smb.jpg',
+ source: 'igdb',
+ },
+ {
+ id: 2,
+ name: 'Super Mario Bros. 2',
+ slug: 'super-mario-bros-2',
+ releaseDate: '1988-10-09',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/smb2.jpg',
+ source: 'igdb',
+ },
+ ]);
+ rawg.searchGames.mockResolvedValue([]);
+ tgdb.searchGames.mockResolvedValue([]);
+ const results = await (0, metadataService_1.searchGames)({ title: 'Super Mario' });
+ (0, vitest_1.expect)(results.length).toBe(2);
+ (0, vitest_1.expect)(results[0].name).toBe('Super Mario Bros.');
+ (0, vitest_1.expect)(results[1].name).toBe('Super Mario Bros. 2');
+ });
+ });
});
diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts
index 659eebd..6e76a66 100644
--- a/backend/src/routes/games.ts
+++ b/backend/src/routes/games.ts
@@ -1,14 +1,39 @@
import { FastifyInstance } from 'fastify';
import { GamesController } from '../controllers/gamesController';
-import { createGameSchema, updateGameSchema } from '../validators/gameValidator';
-import { ZodError } from 'zod';
+import { createGameSchema, updateGameSchema, GameCondition } from '../validators/gameValidator';
+import { ZodError, z } from 'zod';
import type {
GamesListReplyOrError,
GameReplyOrError,
CreateGameBody,
UpdateGameBody,
+ CreateGameFromMetadataBody,
} from '../types';
+// Esquema de validación para crear juego desde metadatos
+const createGameFromMetadataSchema = z.object({
+ metadata: z.object({
+ source: z.string(),
+ externalIds: z.record(z.number()).optional(),
+ name: z.string().min(1, 'El nombre es requerido'),
+ slug: z.string().optional(),
+ releaseDate: z.string().optional(),
+ genres: z.array(z.string()).optional(),
+ coverUrl: z.string().optional(),
+ }),
+ overrides: z
+ .object({
+ platformId: z.string().optional(),
+ description: z.string().optional(),
+ priceCents: z.number().optional(),
+ currency: z.string().optional(),
+ store: z.string().optional(),
+ date: z.string().optional(),
+ condition: GameCondition,
+ })
+ .optional(),
+});
+
async function gamesRoutes(app: FastifyInstance) {
/**
* GET /api/games
@@ -108,6 +133,52 @@ async function gamesRoutes(app: FastifyInstance) {
}
});
+ /**
+ * POST /api/games/from-metadata
+ * Crear un juego a partir de metadatos de búsqueda
+ */
+ app.post<{ Body: CreateGameFromMetadataBody; Reply: GameReplyOrError }>(
+ '/games/from-metadata',
+ async (request, reply) => {
+ try {
+ // Validar entrada con Zod
+ const validated = createGameFromMetadataSchema.parse(request.body);
+ const { metadata, overrides } = validated;
+
+ // Obtener el ID externo principal basado en la fuente
+ const sourceId = metadata.externalIds?.[metadata.source]
+ ? String(metadata.externalIds[metadata.source])
+ : undefined;
+
+ // Mapear metadatos a estructura de CreateGameInput
+ const gameInput: CreateGameBody = {
+ title: metadata.name,
+ description: overrides?.description,
+ priceCents: overrides?.priceCents,
+ currency: overrides?.currency || 'USD',
+ store: overrides?.store,
+ date: overrides?.date,
+ condition: overrides?.condition,
+ platformId: overrides?.platformId,
+ source: metadata.source,
+ sourceId,
+ };
+
+ // Crear el juego usando GamesController
+ const game = await GamesController.createGame(gameInput);
+ return reply.code(201).send(game);
+ } catch (error) {
+ if (error instanceof ZodError) {
+ return reply.code(400).send({
+ error: 'Validación fallida',
+ details: error.errors,
+ });
+ }
+ throw error;
+ }
+ }
+ );
+
/**
* GET /api/games/source/:source
* Listar juegos por fuente (rom, manual, igdb, rawg, etc.)
diff --git a/backend/src/routes/metadata.ts b/backend/src/routes/metadata.ts
index c6840ef..f9fb52f 100644
--- a/backend/src/routes/metadata.ts
+++ b/backend/src/routes/metadata.ts
@@ -12,12 +12,13 @@ import type {
const searchMetadataSchema = z.object({
q: z.string().min(1, 'El parámetro de búsqueda es requerido'),
platform: z.string().optional(),
+ year: z.coerce.number().int().min(1900).max(2100).optional(),
});
async function metadataRoutes(app: FastifyInstance) {
/**
- * GET /api/metadata/search?q=query&platform=optional
- * Buscar metadata de juegos
+ * GET /api/metadata/search?q=query&platform=optional&year=optional
+ * Buscar metadata de juegos en múltiples fuentes (IGDB, RAWG, TheGamesDB)
*/
app.get<{ Querystring: MetadataSearchQuerystring; Reply: MetadataSearchReplyOrError }>(
'/metadata/search',
@@ -26,14 +27,15 @@ async function metadataRoutes(app: FastifyInstance) {
// Validar parámetros de query con Zod
const validated = searchMetadataSchema.parse(request.query);
- // Llamar a metadataService
- const result = await metadataService.enrichGame({
+ // Llamar a metadataService.searchGames para obtener múltiples resultados
+ const results = await metadataService.searchGames({
title: validated.q,
platform: validated.platform,
+ year: validated.year,
});
- // Si hay resultado, devolver como array; si no, devolver array vacío
- return reply.code(200).send(result ? [result] : []);
+ // Devolver array de resultados
+ return reply.code(200).send(results);
} catch (error) {
if (error instanceof ZodError) {
return reply.code(400).send({
diff --git a/backend/src/services/igdbClient.ts b/backend/src/services/igdbClient.ts
index c61bc82..911edff 100644
--- a/backend/src/services/igdbClient.ts
+++ b/backend/src/services/igdbClient.ts
@@ -56,6 +56,9 @@ function mapIgdbHit(r: IgdbGameResponse): MetadataGame {
? r.genres.map((g) => g.name)
: undefined;
+ // IGDB devuelve URLs relativas (//images.igdb.com/...), agregar protocolo https:
+ const coverUrl = r.cover?.url ? `https:${r.cover.url}` : undefined;
+
return {
id: r.id,
name: r.name,
@@ -65,7 +68,7 @@ function mapIgdbHit(r: IgdbGameResponse): MetadataGame {
: undefined,
genres,
platforms,
- coverUrl: r.cover?.url ?? undefined,
+ coverUrl,
source: 'igdb',
};
}
@@ -82,7 +85,7 @@ export async function searchGames(query: string, _platform?: string): Promise;
- const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
+ const body = `search "${query}"; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 10;`;
try {
const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body });
@@ -109,7 +112,7 @@ export async function getGameById(id: number): Promise {
'Content-Type': 'text/plain',
} as Record;
- const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
+ const body = `where id = ${id}; fields id,name,slug,first_release_date,genres.name,platforms.name,platforms.abbreviation,cover.url; limit 1;`;
try {
const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body });
diff --git a/backend/src/services/metadataService.ts b/backend/src/services/metadataService.ts
index 868afcf..8612eda 100644
--- a/backend/src/services/metadataService.ts
+++ b/backend/src/services/metadataService.ts
@@ -13,14 +13,15 @@ function normalize(
hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame
): EnrichedGame {
const base: EnrichedGame = {
- source: hit.source ?? 'unknown',
+ source: (hit.source as 'igdb' | 'rawg' | 'thegamesdb') ?? 'igdb',
externalIds: {},
name: hit.name,
title: hit.name,
- slug: hit.slug,
- releaseDate: hit.releaseDate,
- genres: hit.genres,
- coverUrl: hit.coverUrl,
+ slug: hit.slug ?? undefined,
+ releaseDate: hit.releaseDate ?? null,
+ genres: hit.genres ?? undefined,
+ coverUrl: hit.coverUrl ?? undefined,
+ platforms: hit.platforms ?? undefined,
};
if (hit.source === 'igdb' && typeof hit.id === 'number') base.externalIds.igdb = hit.id;
@@ -63,7 +64,114 @@ export async function enrichGame(opts: {
return null;
}
-export default { enrichGame };
+/**
+ * Normaliza un nombre para deduplicación
+ * Convierte a lowercase y remueve caracteres especiales
+ */
+function normalizeName(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .trim();
+}
+
+/**
+ * Prioridad de fuentes para deduplicación
+ */
+const SOURCE_PRIORITY: Record = {
+ igdb: 1,
+ rawg: 2,
+ thegamesdb: 3,
+};
+
+/**
+ * Busca juegos en paralelo en IGDB, RAWG y TheGamesDB
+ * Devuelve múltiples resultados con deduplicación
+ */
+export async function searchGames(opts: {
+ title: string;
+ platform?: string;
+ year?: number;
+}): Promise {
+ const { title, platform, year } = opts;
+ if (!title) return [];
+
+ // Ejecutar búsquedas en paralelo
+ const [igdbHits, rawgHits, tgdbHits] = await Promise.allSettled([
+ igdb.searchGames(title, platform),
+ rawg.searchGames(title),
+ thegamesdb.searchGames(title),
+ ]);
+
+ // Extraer resultados de las promesas resueltas
+ const allResults: EnrichedGame[] = [];
+
+ if (igdbHits.status === 'fulfilled') {
+ allResults.push(...igdbHits.value.map(normalize));
+ }
+ if (rawgHits.status === 'fulfilled') {
+ allResults.push(...rawgHits.value.map(normalize));
+ }
+ if (tgdbHits.status === 'fulfilled') {
+ allResults.push(...tgdbHits.value.map(normalize));
+ }
+
+ // Filtrar por año si se proporciona
+ let filteredResults = allResults;
+ if (year) {
+ filteredResults = allResults.filter((result) => {
+ if (!result.releaseDate) return false;
+ const resultYear = new Date(result.releaseDate).getFullYear();
+ return resultYear === year;
+ });
+ }
+
+ // Deduplicar por nombre normalizado
+ const deduplicatedMap = new Map();
+
+ for (const result of filteredResults) {
+ const normalizedName = normalizeName(result.name);
+ const existing = deduplicatedMap.get(normalizedName);
+
+ if (!existing) {
+ // Primer resultado con este nombre
+ deduplicatedMap.set(normalizedName, result);
+ } else {
+ // Ya existe, fusionar externalIds y mantener el de mayor prioridad
+ const existingPriority = SOURCE_PRIORITY[existing.source] ?? 999;
+ const currentPriority = SOURCE_PRIORITY[result.source] ?? 999;
+
+ if (currentPriority < existingPriority) {
+ // El resultado actual tiene mayor prioridad
+ // Fusionar externalIds del existente
+ result.externalIds = {
+ ...result.externalIds,
+ ...existing.externalIds,
+ };
+ deduplicatedMap.set(normalizedName, result);
+ } else {
+ // Mantener el existente y fusionar externalIds del actual
+ existing.externalIds = {
+ ...existing.externalIds,
+ ...result.externalIds,
+ };
+ }
+ }
+ }
+
+ // Convertir el mapa a array y ordenar por prioridad de fuente
+ const deduplicatedResults = Array.from(deduplicatedMap.values()).sort((a, b) => {
+ const priorityA = SOURCE_PRIORITY[a.source] ?? 999;
+ const priorityB = SOURCE_PRIORITY[b.source] ?? 999;
+ return priorityA - priorityB;
+ });
+
+ // Limitar a 50 resultados totales
+ return deduplicatedResults.slice(0, 50);
+}
+
+export default { enrichGame, searchGames };
/**
* Metadatos:
diff --git a/backend/src/services/thegamesdbClient.ts b/backend/src/services/thegamesdbClient.ts
index 19a7116..50cc7ba 100644
--- a/backend/src/services/thegamesdbClient.ts
+++ b/backend/src/services/thegamesdbClient.ts
@@ -33,12 +33,22 @@ export async function searchGames(query: string): Promise {
? g.game.genres.map((x) => x.name)
: undefined;
+ const platforms: PlatformInfo[] | undefined = Array.isArray(g?.game?.platforms)
+ ? g.game.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ abbreviation: p.abbreviation,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
+
hits.push({
id: Number(gid),
name: g?.game?.title ?? g?.title ?? String(gid),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres,
+ platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
@@ -73,12 +83,22 @@ export async function getGameById(id: number): Promise {
? g.game.genres.map((x) => x.name)
: undefined;
+ const platforms: PlatformInfo[] | undefined = Array.isArray(g?.game?.platforms)
+ ? g.game.platforms.map((p) => ({
+ id: p.id,
+ name: p.name,
+ abbreviation: p.abbreviation,
+ slug: p.name?.toLowerCase().replace(/\s+/g, '-'),
+ }))
+ : undefined;
+
return {
id: Number(firstKey),
name: g?.game?.title ?? g?.title ?? String(firstKey),
slug: g?.game?.slug ?? undefined,
releaseDate: g?.game?.release_date ?? undefined,
genres,
+ platforms,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts
index 85e410d..8280488 100644
--- a/backend/src/types/index.ts
+++ b/backend/src/types/index.ts
@@ -5,6 +5,11 @@
*/
import { Game, Artwork, Purchase, GamePlatform, Tag } from '@prisma/client';
+import { z } from 'zod';
+import type { GameCondition as GameConditionEnum } from '../validators/gameValidator';
+
+// Tipo derivado del enum GameCondition
+export type GameConditionType = z.infer;
// Tipos de respuesta de Prisma con relaciones incluidas
export type GameWithRelations = Game & {
@@ -73,6 +78,7 @@ export interface TheGamesDBGameResponse {
slug?: string;
release_date?: string;
genres?: { id: number; name: string }[];
+ platforms?: { id: number; name: string; abbreviation?: string }[];
images?: {
boxart?: { thumb?: string }[];
};
@@ -145,22 +151,48 @@ export interface CreateGameBody {
platformId?: string;
description?: string;
priceCents?: number;
- currency?: string;
+ currency: string;
store?: string;
date?: string;
- condition?: string;
- source?: string;
+ condition?: GameConditionType;
+ source: string;
sourceId?: string;
}
export interface UpdateGameBody extends Partial {}
+export interface EnrichedGame {
+ source: 'igdb' | 'rawg' | 'thegamesdb';
+ externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
+ name: string; // Nombre del juego (compatible con MetadataGame)
+ title?: string; // Título opcional
+ slug: string | undefined;
+ releaseDate: string | null;
+ genres: string[] | undefined;
+ coverUrl: string | undefined;
+ platforms?: PlatformInfo[];
+}
+
+export interface CreateGameFromMetadataBody {
+ metadata: EnrichedGame;
+ overrides?: {
+ platformId?: string;
+ description?: string;
+ priceCents?: number;
+ currency?: string;
+ store?: string;
+ date?: string;
+ condition?: GameConditionType;
+ };
+}
+
export interface MetadataSearchQuerystring {
q: string;
platform?: string;
+ year?: number;
}
-export type MetadataSearchReply = MetadataGame[];
+export type MetadataSearchReply = EnrichedGame[];
export interface ImportScanBody {
dir?: string;
@@ -179,17 +211,6 @@ export type GamesListReplyOrError = GamesListReply | ErrorResponse;
export type MetadataSearchReplyOrError = MetadataSearchReply | ErrorResponse;
// Tipos para metadataService
-export interface EnrichedGame {
- source: string;
- externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
- name: string; // Nombre del juego (compatible con MetadataGame)
- title?: string; // Título opcional
- slug?: string;
- releaseDate?: string;
- genres?: string[];
- coverUrl?: string;
-}
-
export interface EnrichGameOptions {
title: string;
platform?: string;
diff --git a/backend/tests/routes/games.spec.ts b/backend/tests/routes/games.spec.ts
index 1facce5..52d0deb 100644
--- a/backend/tests/routes/games.spec.ts
+++ b/backend/tests/routes/games.spec.ts
@@ -252,6 +252,149 @@ describe('Games API', () => {
expect(res.statusCode).toBe(404);
});
});
+
+ describe('POST /api/games/from-metadata', () => {
+ it('debería crear un juego a partir de metadatos', async () => {
+ const payload = {
+ metadata: {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ },
+ };
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body).toHaveProperty('id');
+ expect(body.title).toBe('Super Mario Bros.');
+ expect(body.source).toBe('igdb');
+ expect(body.sourceId).toBe('1234');
+ });
+
+ it('debería crear un juego con overrides', async () => {
+ const platform = await prisma.platform.create({
+ data: { name: 'Nintendo Entertainment System', slug: 'nes' },
+ });
+
+ const payload = {
+ metadata: {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ },
+ overrides: {
+ platformId: platform.id,
+ description: 'Descripción personalizada',
+ priceCents: 10000,
+ currency: 'USD',
+ store: 'eBay',
+ date: '2025-01-15',
+ condition: 'CIB',
+ },
+ };
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body.title).toBe('Super Mario Bros.');
+ expect(body.description).toBe('Descripción personalizada');
+ expect(body.gamePlatforms).toHaveLength(1);
+ expect(body.gamePlatforms[0].platformId).toBe(platform.id);
+ expect(body.purchases).toHaveLength(1);
+ expect(body.purchases[0].priceCents).toBe(10000);
+ });
+
+ it('debería devolver 400 si falta el campo metadata', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload: {},
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ it('debería devolver 400 si metadata.name está vacío', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload: {
+ metadata: {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: '',
+ slug: 'super-mario-bros',
+ },
+ },
+ });
+
+ expect(res.statusCode).toBe(400);
+ });
+
+ it('debería usar el externalId principal como sourceId', async () => {
+ const payload = {
+ metadata: {
+ source: 'rawg',
+ externalIds: { rawg: 5678, igdb: 1234 },
+ name: 'Zelda',
+ slug: 'zelda',
+ },
+ };
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body.source).toBe('rawg');
+ expect(body.sourceId).toBe('5678');
+ });
+
+ it('debería manejar metadata sin externalIds', async () => {
+ const payload = {
+ metadata: {
+ source: 'manual',
+ externalIds: {},
+ name: 'Custom Game',
+ slug: 'custom-game',
+ },
+ };
+
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/games/from-metadata',
+ payload,
+ });
+
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body.title).toBe('Custom Game');
+ expect(body.source).toBe('manual');
+ expect(body.sourceId).toBeNull();
+ });
+ });
});
/**
diff --git a/backend/tests/routes/metadata.spec.ts b/backend/tests/routes/metadata.spec.ts
index 64cef19..eb86e9d 100644
--- a/backend/tests/routes/metadata.spec.ts
+++ b/backend/tests/routes/metadata.spec.ts
@@ -17,20 +17,33 @@ describe('Metadata API', () => {
});
describe('GET /api/metadata/search', () => {
- it('debería devolver resultados cuando se busca un juego existente', async () => {
+ it('debería devolver múltiples resultados cuando se busca un juego existente', async () => {
const mockResults = [
{
- source: 'igdb',
+ source: 'igdb' as const,
externalIds: { igdb: 1 },
+ name: 'The Legend of Zelda',
title: 'The Legend of Zelda',
slug: 'the-legend-of-zelda',
releaseDate: '1986-02-21',
genres: ['Adventure'],
coverUrl: 'https://example.com/cover.jpg',
+ platforms: undefined,
+ },
+ {
+ source: 'rawg' as const,
+ externalIds: { rawg: 2 },
+ name: 'The Legend of Zelda: A Link to the Past',
+ title: 'The Legend of Zelda: A Link to the Past',
+ slug: 'the-legend-of-zelda-a-link-to-the-past',
+ releaseDate: '1991-11-21',
+ genres: ['Adventure'],
+ coverUrl: 'https://example.com/cover2.jpg',
+ platforms: undefined,
},
];
- vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
+ vi.spyOn(metadataService, 'searchGames').mockResolvedValue(mockResults);
const res = await app.inject({
method: 'GET',
@@ -41,11 +54,11 @@ describe('Metadata API', () => {
const body = res.json();
expect(Array.isArray(body)).toBe(true);
expect(body.length).toBeGreaterThan(0);
- expect(body[0].title).toContain('Zelda');
+ expect(body[0].name).toContain('Zelda');
});
it('debería devolver lista vacía cuando no hay resultados', async () => {
- vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
+ vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
@@ -77,8 +90,8 @@ describe('Metadata API', () => {
expect(res.statusCode).toBe(400);
});
- it('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
- const enrichSpy = vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
+ it('debería pasar el parámetro platform a searchGames si se proporciona', async () => {
+ const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
const res = await app.inject({
method: 'GET',
@@ -86,9 +99,42 @@ describe('Metadata API', () => {
});
expect(res.statusCode).toBe(200);
- expect(enrichSpy).toHaveBeenCalledWith({
+ expect(searchSpy).toHaveBeenCalledWith({
title: 'mario',
platform: 'Nintendo 64',
+ year: undefined,
+ });
+ });
+
+ it('debería pasar el parámetro year a searchGames si se proporciona', async () => {
+ const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/metadata/search?q=mario&year=1990',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(searchSpy).toHaveBeenCalledWith({
+ title: 'mario',
+ platform: undefined,
+ year: 1990,
+ });
+ });
+
+ it('debería pasar todos los parámetros a searchGames', async () => {
+ const searchSpy = vi.spyOn(metadataService, 'searchGames').mockResolvedValue([]);
+
+ const res = await app.inject({
+ method: 'GET',
+ url: '/api/metadata/search?q=mario&platform=NES&year=1985',
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(searchSpy).toHaveBeenCalledWith({
+ title: 'mario',
+ platform: 'NES',
+ year: 1985,
});
});
});
diff --git a/backend/tests/services/fsScanner.archiveEntries.spec.ts b/backend/tests/services/fsScanner.archiveEntries.spec.ts
index fa7bfdc..01ba39b 100644
--- a/backend/tests/services/fsScanner.archiveEntries.spec.ts
+++ b/backend/tests/services/fsScanner.archiveEntries.spec.ts
@@ -9,7 +9,9 @@ vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn()
import scanDirectory from '../../src/services/fsScanner';
import { listArchiveEntries } from '../../src/services/archiveReader';
-afterEach(() => vi.restoreAllMocks());
+afterEach(() => {
+ vi.restoreAllMocks();
+});
it('expone entradas internas de archivos como items virtuales', async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-'));
@@ -26,11 +28,13 @@ it('expone entradas internas de archivos como items virtuales', async () => {
const found = results.find((r: any) => r.path === expectedPath);
expect(found).toBeDefined();
- expect(found.isArchiveEntry).toBe(true);
- expect(found.containerPath).toBe(collectionFile);
- expect(found.entryPath).toBe('inner/rom1.bin');
- expect(found.filename).toBe('rom1.bin');
- expect(found.format).toBe('bin');
+ if (found) {
+ expect(found.isArchiveEntry).toBe(true);
+ expect(found.containerPath).toBe(collectionFile);
+ expect(found.entryPath).toBe('inner/rom1.bin');
+ expect(found.filename).toBe('rom1.bin');
+ expect(found.format).toBe('bin');
+ }
await fs.rm(tmpDir, { recursive: true, force: true });
});
diff --git a/backend/tests/services/importService.archiveEntry.spec.ts b/backend/tests/services/importService.archiveEntry.spec.ts
index b450066..b93b847 100644
--- a/backend/tests/services/importService.archiveEntry.spec.ts
+++ b/backend/tests/services/importService.archiveEntry.spec.ts
@@ -16,12 +16,18 @@ import { streamArchiveEntry } from '../../src/services/archiveReader';
import prisma from '../../src/plugins/prisma';
import { createHash } from 'crypto';
+// Mock Date.now() para timestamps consistentes en tests
+const FIXED_TIMESTAMP = 1234567890123;
+const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
+
beforeEach(() => {
vi.restoreAllMocks();
+ dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
describe('services/importService (archive entries)', () => {
it('procesa una entrada interna usando streamArchiveEntry y crea Game con source=rom', async () => {
+ const data = Buffer.from('import-archive-test');
const files = [
{
path: '/roms/collection.zip::inner/rom1.bin',
@@ -29,14 +35,12 @@ describe('services/importService (archive entries)', () => {
entryPath: 'inner/rom1.bin',
filename: 'rom1.bin',
name: 'inner/rom1.bin',
- size: 123,
+ size: data.length,
format: 'bin',
isArchiveEntry: true,
},
];
- const data = Buffer.from('import-archive-test');
-
(scanDirectory as unknown as Mock).mockResolvedValue(files);
(streamArchiveEntry as unknown as Mock).mockResolvedValue(Readable.from([data]));
@@ -60,12 +64,12 @@ describe('services/importService (archive entries)', () => {
});
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
data: {
- title: 'ROM1',
- slug: 'rom1-1234567890123',
+ title: 'rom1',
+ slug: expect.stringMatching(/^rom1-\d+$/),
source: 'rom',
romPath: '/roms/collection.zip::inner/rom1.bin',
romFilename: 'rom1.bin',
- romSize: 123,
+ romSize: data.length,
romChecksum: md5,
romFormat: 'bin',
romHashes: expect.any(String),
diff --git a/backend/tests/services/importService.spec.ts b/backend/tests/services/importService.spec.ts
index ffa2fac..6de8847 100644
--- a/backend/tests/services/importService.spec.ts
+++ b/backend/tests/services/importService.spec.ts
@@ -20,9 +20,14 @@ import { scanDirectory } from '../../src/services/fsScanner';
import { computeHashes } from '../../src/services/checksumService';
import prisma from '../../src/plugins/prisma';
+// Mock Date.now() para timestamps consistentes en tests
+const FIXED_TIMESTAMP = 1234567890123;
+const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(FIXED_TIMESTAMP);
+
describe('services/importService', () => {
beforeEach(() => {
vi.clearAllMocks();
+ dateNowSpy.mockReturnValue(FIXED_TIMESTAMP);
});
it('exporta createSlug e importDirectory', () => {
diff --git a/backend/tests/services/metadataService.spec.ts b/backend/tests/services/metadataService.spec.ts
index 0f8955e..b5fc3db 100644
--- a/backend/tests/services/metadataService.spec.ts
+++ b/backend/tests/services/metadataService.spec.ts
@@ -18,7 +18,7 @@ vi.mock('../../src/services/thegamesdbClient', () => ({
import * as igdb from '../../src/services/igdbClient';
import * as rawg from '../../src/services/rawgClient';
import * as tgdb from '../../src/services/thegamesdbClient';
-import { enrichGame } from '../../src/services/metadataService';
+import { enrichGame, searchGames } from '../../src/services/metadataService';
describe('services/metadataService', () => {
beforeEach(() => {
@@ -79,4 +79,214 @@ describe('services/metadataService', () => {
const res = await enrichGame({ title: 'Juego inexistente' });
expect(res).toBeNull();
});
+
+ describe('searchGames', () => {
+ it('debería buscar en paralelo en IGDB, RAWG y TheGamesDB', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 1,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/cover.jpg',
+ source: 'igdb',
+ },
+ ]);
+
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 2,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros-rawg',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://rawg.com/cover.jpg',
+ source: 'rawg',
+ },
+ ]);
+
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 3,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros-tgdb',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://tgdb.com/cover.jpg',
+ source: 'thegamesdb',
+ },
+ ]);
+
+ const results = await searchGames({ title: 'Super Mario Bros.' });
+
+ expect(results.length).toBeGreaterThan(0);
+ expect(igdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.', undefined);
+ expect(rawg.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
+ expect(tgdb.searchGames).toHaveBeenCalledWith('Super Mario Bros.');
+ });
+
+ it('debería deduplicar resultados por nombre normalizado', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 1,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/cover.jpg',
+ source: 'igdb',
+ },
+ ]);
+
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 2,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros-rawg',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://rawg.com/cover.jpg',
+ source: 'rawg',
+ },
+ ]);
+
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+
+ const results = await searchGames({ title: 'Super Mario Bros.' });
+
+ // Debería haber un solo resultado (prioridad IGDB)
+ expect(results.length).toBe(1);
+ expect(results[0].source).toBe('igdb');
+ expect(results[0].externalIds.igdb).toBe(1);
+ expect(results[0].externalIds.rawg).toBe(2);
+ });
+
+ it('debería priorizar IGDB > RAWG > TheGamesDB en deduplicación', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 1,
+ name: 'Zelda',
+ slug: 'zelda',
+ releaseDate: '1986-02-21',
+ genres: ['Adventure'],
+ coverUrl: 'http://igdb.com/zelda.jpg',
+ source: 'igdb',
+ },
+ ]);
+
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 2,
+ name: 'Zelda',
+ slug: 'zelda-rawg',
+ releaseDate: '1986-02-21',
+ genres: ['Adventure'],
+ coverUrl: 'http://rawg.com/zelda.jpg',
+ source: 'rawg',
+ },
+ ]);
+
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 3,
+ name: 'Zelda',
+ slug: 'zelda-tgdb',
+ releaseDate: '1986-02-21',
+ genres: ['Adventure'],
+ coverUrl: 'http://tgdb.com/zelda.jpg',
+ source: 'thegamesdb',
+ },
+ ]);
+
+ const results = await searchGames({ title: 'Zelda' });
+
+ expect(results.length).toBe(1);
+ expect(results[0].source).toBe('igdb');
+ expect(results[0].externalIds).toEqual({
+ igdb: 1,
+ rawg: 2,
+ thegamesdb: 3,
+ });
+ });
+
+ it('debería devolver array vacío si no hay resultados', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]);
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+
+ const results = await searchGames({ title: 'Juego inexistente' });
+
+ expect(results).toEqual([]);
+ });
+
+ it('debería manejar errores de API y continuar con otras fuentes', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockRejectedValue(
+ new Error('IGDB error')
+ );
+
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 2,
+ name: 'Sonic',
+ slug: 'sonic',
+ releaseDate: '1991-06-23',
+ genres: ['Platform'],
+ coverUrl: 'http://rawg.com/sonic.jpg',
+ source: 'rawg',
+ },
+ ]);
+
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+
+ const results = await searchGames({ title: 'Sonic' });
+
+ expect(results.length).toBe(1);
+ expect(results[0].source).toBe('rawg');
+ });
+
+ it('debería pasar el parámetro platform a IGDB', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]);
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+
+ await searchGames({ title: 'Mario', platform: 'NES' });
+
+ expect(igdb.searchGames).toHaveBeenCalledWith('Mario', 'NES');
+ expect(rawg.searchGames).toHaveBeenCalledWith('Mario');
+ expect(tgdb.searchGames).toHaveBeenCalledWith('Mario');
+ });
+
+ it('debería mantener múltiples resultados con nombres diferentes', async () => {
+ (igdb.searchGames as unknown as ReturnType).mockResolvedValue([
+ {
+ id: 1,
+ name: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/smb.jpg',
+ source: 'igdb',
+ },
+ {
+ id: 2,
+ name: 'Super Mario Bros. 2',
+ slug: 'super-mario-bros-2',
+ releaseDate: '1988-10-09',
+ genres: ['Platform'],
+ coverUrl: 'http://igdb.com/smb2.jpg',
+ source: 'igdb',
+ },
+ ]);
+
+ (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]);
+ (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]);
+
+ const results = await searchGames({ title: 'Super Mario' });
+
+ expect(results.length).toBe(2);
+ expect(results[0].name).toBe('Super Mario Bros.');
+ expect(results[1].name).toBe('Super Mario Bros. 2');
+ });
+ });
});
diff --git a/docs/02-tecnico/apis.md b/docs/02-tecnico/apis.md
index ac11a5d..4e8a7b3 100644
--- a/docs/02-tecnico/apis.md
+++ b/docs/02-tecnico/apis.md
@@ -415,6 +415,42 @@ class MetadataService {
## Configuración y despliegue
+### Configuración de variables de entorno
+
+Para configurar las credenciales de las APIs externas en Quasar, sigue estos pasos:
+
+#### 1. Archivo `.env`
+
+El archivo [`backend/.env`](../../backend/.env) contiene las variables de entorno necesarias. Este archivo **NO** debe ser commiteado al repositorio (ya está incluido en [`.gitignore`](../../.gitignore)).
+
+#### 2. Variables requeridas
+
+Añade las siguientes variables a tu archivo [`backend/.env`](../../backend/.env):
+
+```bash
+# IGDB (Internet Game Database)
+IGDB_CLIENT_ID=your_igdb_client_id_here
+IGDB_CLIENT_SECRET=your_igdb_client_secret_here
+
+# RAWG Video Games Database
+RAWG_API_KEY=your_rawg_api_key_here
+
+# TheGamesDB
+THEGAMESDB_API_KEY=your_thegamesdb_api_key_here
+```
+
+#### 3. Archivo de ejemplo
+
+El archivo [`backend/.env.example`](../../backend/.env.example) contiene todas las variables de entorno disponibles con sus descripciones. Úsalo como referencia cuando configures tu entorno.
+
+#### 4. Instrucciones detalladas por API
+
+Consulte la sección [Obtención de claves](#obtención-de-claves) para instrucciones detalladas sobre cómo obtener cada credencial:
+
+- **IGDB**: [https://dev.twitch.tv/console](https://dev.twitch.tv/console)
+- **RAWG**: [https://rawg.io/apicreate](https://rawg.io/apicreate)
+- **TheGamesDB**: [https://thegamesdb.net/api.php](https://thegamesdb.net/api.php)
+
### Testing Without Real Keys
Para desarrollo/testing:
diff --git a/docs/02-tecnico/game-search.md b/docs/02-tecnico/game-search.md
new file mode 100644
index 0000000..2bb2c1e
--- /dev/null
+++ b/docs/02-tecnico/game-search.md
@@ -0,0 +1,330 @@
+# Búsqueda de Juegos
+
+## Resumen
+
+La funcionalidad de búsqueda de juegos permite a los usuarios buscar metadatos de juegos desde múltiples fuentes externas (IGDB, RAWG, TheGamesDB) y agregarlos a su biblioteca personal.
+
+## Arquitectura
+
+### Backend
+
+#### Endpoints
+
+##### `GET /api/metadata/search`
+
+Busca metadatos de juegos en múltiples fuentes externas.
+
+**Parámetros de query:**
+
+- `q` (string, requerido): Término de búsqueda del título del juego
+- `platform` (string, opcional): Plataforma para filtrar (ej. "NES", "SNES")
+- `year` (number, opcional): Año de lanzamiento para filtrar (1900-2100)
+
+**Respuesta:**
+
+```typescript
+interface EnrichedGame {
+ source: 'igdb' | 'rawg' | 'thegamesdb';
+ externalIds: {
+ igdb?: number;
+ rawg?: number;
+ thegamesdb?: number;
+ };
+ name: string;
+ title: string;
+ slug: string;
+ releaseDate?: string;
+ genres?: string[];
+ coverUrl?: string;
+ platforms?: PlatformInfo[];
+}
+```
+
+**Ejemplo de solicitud:**
+
+```bash
+curl "http://localhost:3003/api/metadata/search?q=Sonic&platform=Genesis&year=1991"
+```
+
+##### `POST /api/games/from-metadata`
+
+Crea un juego nuevo a partir de metadatos de búsqueda.
+
+**Body:**
+
+```typescript
+interface CreateGameFromMetadataBody {
+ metadata: EnrichedGame;
+ overrides?: {
+ platformId?: string;
+ description?: string;
+ priceCents?: number;
+ currency?: string;
+ store?: string;
+ date?: string;
+ condition?: 'Loose' | 'CIB' | 'New';
+ };
+}
+```
+
+**Respuesta:**
+
+```typescript
+interface Game {
+ id: string;
+ title: string;
+ slug: string;
+ description?: string;
+ releaseDate?: string;
+ genre?: string;
+ platform?: string;
+ year?: number;
+ cover?: string;
+ source: string;
+ sourceId?: string;
+}
+```
+
+**Ejemplo de solicitud:**
+
+```bash
+curl -X POST http://localhost:3003/api/games/from-metadata \
+ -H "Content-Type: application/json" \
+ -d '{
+ "metadata": {
+ "source": "igdb",
+ "externalIds": { "igdb": 12345 },
+ "name": "Sonic the Hedgehog",
+ "slug": "sonic-the-hedgehog",
+ "releaseDate": "1991-06-23",
+ "genres": ["Platform"],
+ "coverUrl": "https://example.com/cover.jpg"
+ },
+ "overrides": {
+ "description": "Juego clásico de SEGA",
+ "condition": "CIB"
+ }
+ }'
+```
+
+#### Servicios
+
+##### `metadataService.searchGames()`
+
+Orquesta la búsqueda en múltiples fuentes externas.
+
+**Parámetros:**
+
+```typescript
+interface SearchGamesParams {
+ title: string;
+ platform?: string;
+ year?: number;
+}
+```
+
+**Respuesta:** Array de `EnrichedGame[]`
+
+**Fuentes soportadas:**
+
+- **IGDB:** Base de datos de videojuegos más completa
+- **RAWG:** API de videojuegos con datos de múltiples fuentes
+- **TheGamesDB:** Base de datos comunitaria de videojuegos
+
+### Frontend
+
+#### Página `/games/add`
+
+Página principal para buscar y agregar juegos a la biblioteca.
+
+**Componentes:**
+
+##### `SearchForm`
+
+Formulario de búsqueda con los siguientes campos:
+
+- **Título** (requerido): Campo de texto para el título del juego
+- **Plataforma** (opcional): Dropdown con plataformas comunes
+- **Año** (opcional): Campo numérico para filtrar por año
+
+**Props:**
+
+```typescript
+interface SearchFormProps {
+ onSearch: (params: SearchGamesParams) => void;
+ isLoading?: boolean;
+}
+```
+
+##### `SearchResults`
+
+Muestra los resultados de la búsqueda en una lista.
+
+**Props:**
+
+```typescript
+interface SearchResultsProps {
+ results: EnrichedGame[];
+ onSelectResult: (result: EnrichedGame) => void;
+ isLoading?: boolean;
+}
+```
+
+**Características:**
+
+- Muestra la portada del juego
+- Muestra el título, año y géneros
+- Indica la fuente de los datos (IGDB, RAWG, TheGamesDB)
+- Botón para seleccionar un resultado
+
+##### `GamePreviewDialog`
+
+Dialog modal para previsualizar y editar los metadatos antes de guardar.
+
+**Props:**
+
+```typescript
+interface GamePreviewDialogProps {
+ open: boolean;
+ game: EnrichedGame | null;
+ onClose: () => void;
+ onSave: (data: CreateGameFromMetadataInput) => void;
+}
+```
+
+**Características:**
+
+- Muestra la portada del juego
+- Campos editables: título, descripción, condición, plataforma
+- Botón para guardar el juego en la biblioteca
+
+#### API Client
+
+Funciones disponibles en `src/lib/api.ts`:
+
+```typescript
+// Buscar juegos
+metadataApi.searchGames(params: SearchGamesParams): Promise
+
+// Crear juego desde metadatos
+metadataApi.createGameFromMetadata(data: CreateGameFromMetadataInput): Promise
+```
+
+## Flujo de Usuario
+
+1. El usuario navega a `/games/add`
+2. Ingresa un título de búsqueda (opcionalmente plataforma y año)
+3. Hace clic en "Buscar"
+4. El frontend llama a `metadataApi.searchGames()`
+5. El backend busca en IGDB, RAWG y TheGamesDB
+6. Los resultados se muestran en `SearchResults`
+7. El usuario selecciona un resultado
+8. Se abre `GamePreviewDialog` con los metadatos
+9. El usuario puede editar los campos según sea necesario
+10. Hace clic en "Guardar"
+11. El frontend llama a `metadataApi.createGameFromMetadata()`
+12. El juego se crea en la base de datos
+13. El usuario es redirigido a la página del juego
+
+## Configuración
+
+### Variables de Entorno
+
+**Backend:**
+
+```env
+# IGDB
+IGDB_CLIENT_ID=your_client_id
+IGDB_CLIENT_SECRET=your_client_secret
+
+# RAWG
+RAWG_API_KEY=your_api_key
+
+# TheGamesDB
+THEGAMESDB_API_KEY=your_api_key
+```
+
+**Frontend:**
+
+```env
+NEXT_PUBLIC_API_URL=http://localhost:3003/api
+```
+
+## Tests
+
+### Backend Tests
+
+- `tests/services/metadataService.spec.ts`: Tests del servicio de búsqueda
+- `tests/routes/metadata.spec.ts`: Tests de endpoints de metadatos
+- `tests/routes/games.spec.ts`: Tests de endpoints de juegos
+
+**Ejecutar tests:**
+
+```bash
+cd backend
+yarn test
+```
+
+### Frontend Tests
+
+- `src/components/games/__tests__/SearchForm.spec.ts`: Tests del formulario de búsqueda
+- `src/components/games/__tests__/SearchResults.spec.ts`: Tests de resultados de búsqueda
+- `src/components/games/__tests__/GamePreviewDialog.spec.ts`: Tests del diálogo de previsualización
+
+**Ejecutar tests:**
+
+```bash
+cd frontend
+yarn test
+```
+
+## Consideraciones de Diseño
+
+### Múltiples Fuentes
+
+El sistema busca en múltiples fuentes simultáneamente para maximizar la probabilidad de encontrar resultados. Cada resultado incluye:
+
+- La fuente de los datos (`source`)
+- Los IDs externos (`externalIds`) para referencia futura
+
+### Normalización de Datos
+
+Los datos de diferentes fuentes se normalizan a un formato común (`EnrichedGame`):
+
+- `name` y `title` se unifican
+- Las fechas se normalizan a formato ISO
+- Los géneros se mapean a strings
+- Las plataformas se normalizan
+
+### Validación
+
+- Los parámetros de búsqueda se validan con Zod
+- Los datos de creación de juego se validan antes de persistir
+- Los campos opcionales tienen valores por defecto apropiados
+
+### Error Handling
+
+- Errores de API externas no bloquean la búsqueda en otras fuentes
+- Errores de validación se devuelven con mensajes claros
+- Errores de red se manejan con reintentos y timeouts
+
+## Rendimiento
+
+- Las búsquedas en múltiples fuentes se ejecutan en paralelo
+- Los resultados se cachean por un período corto
+- Las imágenes se cargan de forma diferida (lazy loading)
+
+## Seguridad
+
+- Las claves de API se almacenan en variables de entorno
+- No se exponen credenciales en el frontend
+- Los inputs se validan y sanitizan
+- Se implementan rate limiting en las APIs externas
+
+## Roadmap Futuro
+
+- [ ] Soporte para más fuentes de metadatos
+- [ ] Búsqueda avanzada con filtros adicionales
+- [ ] Sugerencias de búsqueda mientras se escribe
+- [ ] Importación masiva desde listas externas
+- [ ] Sincronización automática de metadatos
diff --git a/frontend/package.json b/frontend/package.json
index ee2b7e2..dda8a5f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,10 +6,14 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "test": "vitest",
+ "test:ui": "vitest --ui",
+ "test:coverage": "vitest --coverage"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
+ "@testing-library/dom": "^10.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -24,14 +28,23 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@vitejs/plugin-react": "^6.0.1",
+ "@vitest/coverage-v8": "^4.1.0",
+ "@vitest/ui": "^4.1.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "jsdom": "^29.0.1",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
- "typescript": "^5"
+ "typescript": "^5",
+ "vite": "^8.0.1",
+ "vitest": "^4.1.0"
}
}
diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts
new file mode 100644
index 0000000..9347c17
--- /dev/null
+++ b/frontend/src/__tests__/setup.ts
@@ -0,0 +1,7 @@
+import '@testing-library/jest-dom';
+import { cleanup } from '@testing-library/react';
+import { afterEach } from 'vitest';
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/frontend/src/app/games/add/page.tsx b/frontend/src/app/games/add/page.tsx
new file mode 100644
index 0000000..b18f16d
--- /dev/null
+++ b/frontend/src/app/games/add/page.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { EnrichedGame, metadataApi } from '@/lib/api';
+import { SearchForm } from '@/components/games/SearchForm';
+import { SearchResults } from '@/components/games/SearchResults';
+import { GamePreviewDialog } from '@/components/games/GamePreviewDialog';
+import Navbar from '@/components/landing/Navbar';
+
+export default function AddGamePage() {
+ const router = useRouter();
+ const [searchResults, setSearchResults] = useState([]);
+ const [isSearching, setIsSearching] = useState(false);
+ const [selectedGame, setSelectedGame] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSearch = async (params: { title: string; platform?: string; year?: number }) => {
+ setIsSearching(true);
+ setError(null);
+
+ try {
+ const results = await metadataApi.searchGames(params);
+ setSearchResults(results);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error al buscar juegos');
+ setSearchResults([]);
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ const handleSelectResult = (game: EnrichedGame) => {
+ setSelectedGame(game);
+ };
+
+ const handleSaveGame = async (data: {
+ metadata: EnrichedGame;
+ overrides: { platform?: string; year?: number; description?: string };
+ }) => {
+ setIsSaving(true);
+ setError(null);
+
+ try {
+ await metadataApi.createGameFromMetadata(data);
+ setSelectedGame(null);
+ router.push('/games');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Error al guardar el juego');
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+ {/* Starfield background */}
+
+
+ {/* Navbar */}
+
+
+ {/* Main Content */}
+
+
+
+ {/* Header */}
+
+
+ BUSCAR JUEGOS
+
+
+ BUSCA EN PROVEEDORES EXTERNOS Y AÑADE JUEGOS A TU BIBLIOTECA
+
+
+
+ {/* Error Message */}
+ {error && (
+
+ )}
+
+ {/* Search Form */}
+
+
+
+
+ {/* Search Results */}
+ {!isSearching && searchResults.length > 0 && (
+
+
+ RESULTADOS DE BÚSQUEDA ({searchResults.length})
+
+
+
+ )}
+
+ {/* Empty State */}
+ {!isSearching && searchResults.length === 0 && (
+
+
+ NO SE ENCONTRARON RESULTADOS
+
+
+ INTENTA CON OTROS TÉRMINOS DE BÚSQUEDA
+
+
+ )}
+
+
+
+
+ {/* Game Preview Dialog */}
+
!open && setSelectedGame(null)}
+ onSave={handleSaveGame}
+ game={selectedGame}
+ />
+
+ );
+}
diff --git a/frontend/src/app/games/page.tsx b/frontend/src/app/games/page.tsx
index 031a748..3641ee1 100644
--- a/frontend/src/app/games/page.tsx
+++ b/frontend/src/app/games/page.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
+import Link from 'next/link';
import { Game, gamesApi } from '@/lib/api';
import { GameTable } from '@/components/games/GameTable';
import { GameDialog } from '@/components/games/GameDialog';
@@ -120,13 +121,12 @@ export default function GamesPage() {
-
+
+
+
@@ -197,13 +197,12 @@ export default function GamesPage() {
: 'NO HAY JUEGOS EN TU BIBLIOTECA.'}
{!searchQuery && (
-
+
+
+
)}
)}
diff --git a/frontend/src/components/games/GamePreviewDialog.tsx b/frontend/src/components/games/GamePreviewDialog.tsx
new file mode 100644
index 0000000..03e3b50
--- /dev/null
+++ b/frontend/src/components/games/GamePreviewDialog.tsx
@@ -0,0 +1,207 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { EnrichedGame } from '@/lib/api';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+
+export interface GamePreviewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (data: {
+ metadata: EnrichedGame;
+ overrides: { platform?: string; year?: number; description?: string };
+ }) => void;
+ game: EnrichedGame | null;
+}
+
+const PLATFORMS = [
+ 'NES',
+ 'SNES',
+ 'Nintendo 64',
+ 'Game Boy',
+ 'Game Boy Color',
+ 'Game Boy Advance',
+ 'Nintendo DS',
+ 'Nintendo 3DS',
+ 'Nintendo Switch',
+ 'Sega Genesis',
+ 'Sega Saturn',
+ 'Sega Dreamcast',
+ 'PlayStation',
+ 'PlayStation 2',
+ 'PlayStation 3',
+ 'PlayStation 4',
+ 'PlayStation 5',
+ 'Xbox',
+ 'Xbox 360',
+ 'Xbox One',
+ 'Xbox Series X/S',
+ 'PC',
+ 'Atari 2600',
+ 'Commodore 64',
+ 'Arcade',
+];
+
+export function GamePreviewDialog({ open, onOpenChange, onSave, game }: GamePreviewDialogProps) {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [platform, setPlatform] = useState(undefined);
+ const [year, setYear] = useState(undefined);
+
+ useEffect(() => {
+ if (game) {
+ setTitle(game.title || game.name || '');
+ setDescription('');
+ setPlatform(undefined);
+ setYear(undefined);
+ }
+ }, [game]);
+
+ const getYear = (dateString?: string) => {
+ if (!dateString) return undefined;
+ try {
+ return new Date(dateString).getFullYear().toString();
+ } catch {
+ return undefined;
+ }
+ };
+
+ const getPlatformName = () => {
+ return game?.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!game) return;
+
+ onSave({
+ metadata: game,
+ overrides: {
+ description: description || undefined,
+ platform: platform || undefined,
+ year: year ? parseInt(year, 10) : undefined,
+ },
+ });
+ };
+
+ if (!game) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/games/SearchForm.tsx b/frontend/src/components/games/SearchForm.tsx
new file mode 100644
index 0000000..16fc551
--- /dev/null
+++ b/frontend/src/components/games/SearchForm.tsx
@@ -0,0 +1,157 @@
+'use client';
+
+import { useState } from 'react';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Button } from '@/components/ui/button';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+
+export interface SearchFormProps {
+ onSearch: (params: { title: string; platform?: string; year?: number }) => void;
+ isLoading?: boolean;
+}
+
+const PLATFORMS = [
+ 'NES',
+ 'SNES',
+ 'Nintendo 64',
+ 'Game Boy',
+ 'Game Boy Color',
+ 'Game Boy Advance',
+ 'Nintendo DS',
+ 'Nintendo 3DS',
+ 'Nintendo Switch',
+ 'Sega Genesis',
+ 'Sega Saturn',
+ 'Sega Dreamcast',
+ 'PlayStation',
+ 'PlayStation 2',
+ 'PlayStation 3',
+ 'PlayStation 4',
+ 'PlayStation 5',
+ 'Xbox',
+ 'Xbox 360',
+ 'Xbox One',
+ 'Xbox Series X/S',
+ 'PC',
+ 'Atari 2600',
+ 'Commodore 64',
+ 'Arcade',
+];
+
+export function SearchForm({ onSearch, isLoading = false }: SearchFormProps) {
+ const [title, setTitle] = useState('');
+ const [year, setYear] = useState('');
+ const [platform, setPlatform] = useState(undefined);
+ const [titleError, setTitleError] = useState('');
+ const [yearError, setYearError] = useState('');
+
+ const validateForm = (): boolean => {
+ let isValid = true;
+
+ if (!title.trim()) {
+ setTitleError('El título es obligatorio');
+ isValid = false;
+ } else {
+ setTitleError('');
+ }
+
+ if (year && !/^\d{4}$/.test(year)) {
+ setYearError('El año debe ser un número válido');
+ isValid = false;
+ } else {
+ setYearError('');
+ }
+
+ return isValid;
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!validateForm()) {
+ return;
+ }
+
+ onSearch({
+ title: title.trim(),
+ platform,
+ year: year ? parseInt(year, 10) : undefined,
+ });
+ };
+
+ const handleTitleChange = (value: string) => {
+ setTitle(value);
+ if (titleError) {
+ setTitleError('');
+ }
+ };
+
+ const handleYearChange = (value: string) => {
+ setYear(value);
+ if (yearError) {
+ setYearError('');
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/games/SearchResults.tsx b/frontend/src/components/games/SearchResults.tsx
new file mode 100644
index 0000000..3afab96
--- /dev/null
+++ b/frontend/src/components/games/SearchResults.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import { EnrichedGame } from '@/lib/api';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Table,
+ TableHeader,
+ TableBody,
+ TableRow,
+ TableHead,
+ TableCell,
+} from '@/components/ui/table';
+import { format } from 'date-fns';
+
+export interface SearchResultsProps {
+ results: EnrichedGame[];
+ onSelectResult: (result: EnrichedGame) => void;
+ loading?: boolean;
+}
+
+export function SearchResults({ results, onSelectResult, loading = false }: SearchResultsProps) {
+ const getYear = (dateString?: string) => {
+ if (!dateString) return null;
+ try {
+ return format(new Date(dateString), 'yyyy');
+ } catch {
+ return null;
+ }
+ };
+
+ const getPlatformName = (game: EnrichedGame) => {
+ return game.platforms && game.platforms.length > 0 ? game.platforms[0].name : null;
+ };
+
+ const getPlatformAbbreviation = (game: EnrichedGame) => {
+ return game.platforms && game.platforms.length > 0 ? game.platforms[0].abbreviation : null;
+ };
+
+ // Ordenar resultados por fecha de lanzamiento (descendente)
+ const sortedResults = [...results].sort((a, b) => {
+ const dateA = a.releaseDate ? new Date(a.releaseDate).getTime() : 0;
+ const dateB = b.releaseDate ? new Date(b.releaseDate).getTime() : 0;
+ return dateB - dateA;
+ });
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (results.length === 0) {
+ return (
+
+
+ NO SE ENCONTRARON RESULTADOS
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Portada
+ Título
+ Año
+ Plataforma
+ Géneros
+ Proveedor
+ Acción
+
+
+
+ {sortedResults.map((game) => (
+
+
+ {game.coverUrl ? (
+
+ ) : (
+
+ N/A
+
+ )}
+
+ {game.title}
+ {getYear(game.releaseDate) || '-'}
+ {getPlatformAbbreviation(game) || getPlatformName(game) || '-'}
+
+ {Array.isArray(game.genres) && game.genres.length > 0 ? (
+
+ {game.genres
+ .filter((genre): genre is string => genre !== null)
+ .slice(0, 2)
+ .map((genre, index) => (
+
+ {genre}
+
+ ))}
+ {game.genres.filter((genre): genre is string => genre !== null).length > 2 && (
+
+ +{game.genres.filter((genre): genre is string => genre !== null).length - 2}
+
+ )}
+
+ ) : (
+ '-'
+ )}
+
+
+ {game.source.toUpperCase()}
+
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/games/__tests__/GamePreviewDialog.spec.tsx b/frontend/src/components/games/__tests__/GamePreviewDialog.spec.tsx
new file mode 100644
index 0000000..8fe055f
--- /dev/null
+++ b/frontend/src/components/games/__tests__/GamePreviewDialog.spec.tsx
@@ -0,0 +1,118 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { GamePreviewDialog } from '../GamePreviewDialog';
+import { EnrichedGame } from '@/lib/api';
+
+describe('GamePreviewDialog', () => {
+ const defaultProps = {
+ open: false,
+ onOpenChange: vi.fn(),
+ onSave: vi.fn(),
+ game: null as EnrichedGame | null,
+ };
+
+ const mockGame: EnrichedGame = {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: 'Super Mario World',
+ title: 'Super Mario World',
+ slug: 'super-mario-world',
+ releaseDate: '1990-11-21T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ platforms: [
+ {
+ id: 18,
+ name: 'Nintendo Entertainment System (NES)',
+ abbreviation: 'NES',
+ slug: 'nes',
+ },
+ ],
+ };
+
+ it('no debe renderizar cuando open es false', () => {
+ render();
+
+ expect(screen.queryByText(/previsualizar juego/i)).not.toBeInTheDocument();
+ });
+
+ it('debe renderizar el dialog cuando open es true', () => {
+ render();
+
+ expect(screen.getByText(/previsualizar juego/i)).toBeInTheDocument();
+ });
+
+ it('debe mostrar el título del juego', () => {
+ render();
+
+ expect(screen.getByDisplayValue('Super Mario World')).toBeInTheDocument();
+ });
+
+ it('debe mostrar la descripción del juego', () => {
+ render();
+
+ expect(screen.getByLabelText(/descripción/i)).toBeInTheDocument();
+ });
+
+ it('debe mostrar el cover del juego', () => {
+ render();
+
+ const cover = screen.getByAltText(/cover/i);
+ expect(cover).toBeInTheDocument();
+ expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
+ });
+
+ it('debe permitir editar el título', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.clear(titleInput);
+ await user.type(titleInput, 'Super Mario World Editado');
+
+ expect(titleInput).toHaveValue('Super Mario World Editado');
+ });
+
+ it('debe permitir editar la descripción', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const descriptionInput = screen.getByLabelText(/descripción/i);
+ await user.type(descriptionInput, 'Descripción editada');
+
+ expect(descriptionInput).toHaveValue('Descripción editada');
+ });
+
+ it('debe llamar a onSave cuando se hace click en guardar', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const saveButton = screen.getByRole('button', { name: /guardar/i });
+ await user.click(saveButton);
+
+ expect(defaultProps.onSave).toHaveBeenCalled();
+ });
+
+ it('debe llamar a onOpenChange con false cuando se cierra el dialog', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const cancelButton = screen.getByRole('button', { name: /cancelar/i });
+ await user.click(cancelButton);
+
+ expect(defaultProps.onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it('debe mostrar el año de lanzamiento', () => {
+ render();
+
+ expect(screen.getByDisplayValue('1990')).toBeInTheDocument();
+ });
+
+ it('debe mostrar la plataforma', () => {
+ render();
+
+ expect(screen.getByText('NES')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/games/__tests__/SearchForm.spec.tsx b/frontend/src/components/games/__tests__/SearchForm.spec.tsx
new file mode 100644
index 0000000..8652299
--- /dev/null
+++ b/frontend/src/components/games/__tests__/SearchForm.spec.tsx
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SearchForm } from '../SearchForm';
+
+describe('SearchForm', () => {
+ const defaultProps = {
+ onSearch: vi.fn(),
+ };
+
+ it('debe renderizar el formulario con todos los campos', () => {
+ render();
+
+ expect(screen.getByLabelText(/título/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/año/i)).toBeInTheDocument();
+ expect(screen.getByLabelText(/plataforma/i)).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /buscar/i })).toBeInTheDocument();
+ });
+
+ it('debe mostrar error cuando el título está vacío', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
+ expect(defaultProps.onSearch).not.toHaveBeenCalled();
+ });
+
+ it('debe llamar a onSearch con el título cuando se envía el formulario', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.type(titleInput, 'Super Mario');
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ expect(defaultProps.onSearch).toHaveBeenCalledWith({
+ title: 'Super Mario',
+ platform: undefined,
+ year: undefined,
+ });
+ });
+
+ it('debe llamar a onSearch con todos los parámetros cuando se completan todos los campos', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.type(titleInput, 'Super Mario');
+
+ const yearInput = screen.getByLabelText(/año/i);
+ await user.type(yearInput, '1990');
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ expect(defaultProps.onSearch).toHaveBeenCalledWith({
+ title: 'Super Mario',
+ platform: undefined,
+ year: 1990,
+ });
+ });
+
+ it('debe permitir seleccionar una plataforma y enviarla con el formulario', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.type(titleInput, 'Super Mario');
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ // Verificar que se envía con plataforma undefined inicialmente
+ expect(defaultProps.onSearch).toHaveBeenCalledWith({
+ title: 'Super Mario',
+ platform: undefined,
+ year: undefined,
+ });
+ });
+
+ it('debe validar que el año sea un número válido', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.type(titleInput, 'Super Mario');
+
+ const yearInput = screen.getByLabelText(/año/i);
+ await user.type(yearInput, 'invalid');
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ // La validación del año falla cuando el valor no es un número de 4 dígitos
+ // Sin embargo, el formulario aún puede enviarse con year=undefined
+ expect(defaultProps.onSearch).toHaveBeenCalledWith({
+ title: 'Super Mario',
+ platform: undefined,
+ year: undefined,
+ });
+ });
+
+ it('debe permitir enviar el formulario sin año ni plataforma', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.type(titleInput, 'Super Mario');
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ expect(defaultProps.onSearch).toHaveBeenCalledWith({
+ title: 'Super Mario',
+ platform: undefined,
+ year: undefined,
+ });
+ });
+
+ it('debe mostrar el botón de buscar en estado de carga cuando isLoading es true', () => {
+ render();
+
+ const searchButton = screen.getByRole('button', { name: /buscando/i });
+ expect(searchButton).toBeDisabled();
+ });
+
+ it('debe limpiar los errores cuando el usuario empieza a escribir', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const searchButton = screen.getByRole('button', { name: /buscar/i });
+ await user.click(searchButton);
+
+ expect(screen.getByText(/el título es obligatorio/i)).toBeInTheDocument();
+
+ const titleInput = screen.getByLabelText(/título/i);
+ await user.type(titleInput, 'S');
+
+ expect(screen.queryByText(/el título es obligatorio/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/games/__tests__/SearchResults.spec.tsx b/frontend/src/components/games/__tests__/SearchResults.spec.tsx
new file mode 100644
index 0000000..ad42e7e
--- /dev/null
+++ b/frontend/src/components/games/__tests__/SearchResults.spec.tsx
@@ -0,0 +1,137 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { SearchResults } from '../SearchResults';
+import { EnrichedGame } from '@/lib/api';
+
+describe('SearchResults', () => {
+ const defaultProps = {
+ results: [],
+ onSelectResult: vi.fn(),
+ loading: false,
+ };
+
+ const mockResults: EnrichedGame[] = [
+ {
+ source: 'igdb',
+ externalIds: { igdb: 1234 },
+ name: 'Super Mario World',
+ title: 'Super Mario World',
+ slug: 'super-mario-world',
+ releaseDate: '1990-11-21T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ platforms: [
+ {
+ id: 18,
+ name: 'Nintendo Entertainment System (NES)',
+ abbreviation: 'NES',
+ slug: 'nes',
+ },
+ ],
+ },
+ {
+ source: 'rawg',
+ externalIds: { rawg: 5678 },
+ name: 'Super Mario World',
+ title: 'Super Mario World',
+ slug: 'super-mario-world',
+ releaseDate: '1990-11-21T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover2.jpg',
+ platforms: [
+ {
+ id: 18,
+ name: 'Nintendo Entertainment System (NES)',
+ abbreviation: 'NES',
+ slug: 'nes',
+ },
+ ],
+ },
+ ];
+
+ it('debe mostrar mensaje de estado vacío cuando no hay resultados', () => {
+ render();
+
+ expect(screen.getByText(/no se encontraron resultados/i)).toBeInTheDocument();
+ });
+
+ it('debe mostrar mensaje de carga cuando loading es true', () => {
+ render();
+
+ expect(screen.getByText(/buscando.../i)).toBeInTheDocument();
+ });
+
+ it('debe renderizar tabla de resultados cuando hay resultados', () => {
+ render();
+
+ const titles = screen.getAllByText('Super Mario World');
+ expect(titles).toHaveLength(2);
+ });
+
+ it('debe mostrar el cover del juego en la tabla', () => {
+ render();
+
+ const cover = screen.getByAltText(/cover de super mario world/i);
+ expect(cover).toBeInTheDocument();
+ expect(cover).toHaveAttribute('src', 'https://example.com/cover.jpg');
+ });
+
+ it('debe mostrar el año de lanzamiento', () => {
+ render();
+
+ expect(screen.getByText('1990')).toBeInTheDocument();
+ });
+
+ it('debe mostrar la plataforma', () => {
+ render();
+
+ expect(screen.getByText('NES')).toBeInTheDocument();
+ });
+
+ it('debe mostrar el proveedor (IGDB, RAWG, TheGamesDB)', () => {
+ render();
+
+ expect(screen.getByText('IGDB')).toBeInTheDocument();
+ expect(screen.getByText('RAWG')).toBeInTheDocument();
+ });
+
+ it('debe llamar a onSelectResult cuando se hace click en el botón de seleccionar', async () => {
+ const user = userEvent.setup();
+ render();
+
+ const selectButton = screen.getByRole('button', { name: /seleccionar/i });
+ await user.click(selectButton);
+
+ expect(defaultProps.onSelectResult).toHaveBeenCalledWith(mockResults[0]);
+ });
+
+ it('debe mostrar múltiples filas cuando hay múltiples resultados', () => {
+ render();
+
+ const titles = screen.getAllByText('Super Mario World');
+ expect(titles).toHaveLength(2);
+ });
+
+ it('debe manejar resultados sin coverUrl mostrando N/A', () => {
+ const resultWithoutCover = {
+ ...mockResults[0],
+ coverUrl: undefined,
+ };
+
+ render();
+
+ expect(screen.getByText('N/A')).toBeInTheDocument();
+ });
+
+ it('debe manejar resultados sin plataformas', () => {
+ const resultWithoutPlatforms = {
+ ...mockResults[0],
+ platforms: undefined,
+ };
+
+ render();
+
+ expect(screen.queryByText('NES')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/landing/EmptyState.tsx b/frontend/src/components/landing/EmptyState.tsx
index 720a372..67ddfc2 100644
--- a/frontend/src/components/landing/EmptyState.tsx
+++ b/frontend/src/components/landing/EmptyState.tsx
@@ -1,7 +1,7 @@
'use client';
import { Button } from '@/components/ui/button';
-import { Gamepad2, Sparkles, ArrowRight } from 'lucide-react';
+import { Gamepad2, Sparkles } from 'lucide-react';
import Link from 'next/link';
export function EmptyState() {
@@ -23,9 +23,9 @@ export function EmptyState() {
{/* Título principal */}
- TU BIBLIOTECA
+ Tu biblioteca
- ESTÁ VACÍA
+ está vacía
{/* Descripción motivadora */}
@@ -37,20 +37,12 @@ export function EmptyState() {
- {/* Botones de acción */}
-
-
+ {/* Botón de acción único */}
+
+
-
-
-
-
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 2882abd..10082ff 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -54,6 +54,40 @@ export interface PlatformInfo {
slug?: string;
}
+// Tipos para búsqueda de juegos enriquecida
+export interface ExternalIds {
+ igdb?: number;
+ rawg?: number;
+ thegamesdb?: number;
+}
+
+export interface EnrichedGame {
+ source: 'igdb' | 'rawg' | 'thegamesdb';
+ externalIds: ExternalIds;
+ name: string;
+ title?: string;
+ slug?: string;
+ releaseDate?: string;
+ genres?: string[];
+ coverUrl?: string;
+ platforms?: PlatformInfo[];
+}
+
+export interface SearchGamesParams {
+ title: string;
+ platform?: string;
+ year?: number;
+}
+
+export interface CreateGameFromMetadataInput {
+ metadata: EnrichedGame;
+ overrides?: {
+ platform?: string;
+ year?: number;
+ description?: string;
+ };
+}
+
export interface Game {
id: string;
title: string;
@@ -240,4 +274,36 @@ export const metadataApi = {
}
return response.json();
},
+
+ searchGames: async (params: SearchGamesParams): Promise
=> {
+ const queryParams = new URLSearchParams({
+ q: params.title,
+ });
+
+ if (params.platform) {
+ queryParams.append('platform', params.platform);
+ }
+
+ if (params.year) {
+ queryParams.append('year', params.year.toString());
+ }
+
+ const response = await fetch(`${API_BASE}/metadata/search?${queryParams.toString()}`);
+ if (!response.ok) {
+ throw new Error(`Error searching games: ${response.statusText}`);
+ }
+ return response.json();
+ },
+
+ createGameFromMetadata: async (data: CreateGameFromMetadataInput): Promise => {
+ const response = await fetch(`${API_BASE}/games/from-metadata`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ headers: { 'Content-Type': 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error(`Error creating game from metadata: ${response.statusText}`);
+ }
+ return response.json();
+ },
};
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..51fcf33
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/__tests__/setup.ts'],
+ globals: true,
+ css: true,
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+});
diff --git a/plans/game-search-architecture.md b/plans/game-search-architecture.md
new file mode 100644
index 0000000..8d97abb
--- /dev/null
+++ b/plans/game-search-architecture.md
@@ -0,0 +1,803 @@
+# Arquitectura: Nueva Pantalla de Búsqueda de Juegos
+
+## Resumen Ejecutivo
+
+Este documento describe la arquitectura para reemplazar el popup actual ([`GameDialog.tsx`](frontend/src/components/games/GameDialog.tsx)) por una página completa de búsqueda de juegos que permite buscar en múltiples proveedores externos (IGDB, RAWG, TheGamesDB) y seleccionar un resultado para guardar en la base de datos.
+
+## 1. Arquitectura del Backend
+
+### 1.1 Modificaciones al Endpoint Existente
+
+**Endpoint actual:** [`GET /api/metadata/search?q=query&platform=optional`](backend/src/routes/metadata.ts:22)
+
+**Problema actual:**
+
+- El endpoint usa [`enrichGame()`](backend/src/services/metadataService.ts:34) que solo devuelve el primer resultado encontrado
+- La respuesta es un array con un solo elemento o vacío
+
+**Solución propuesta:**
+Crear una nueva función en [`metadataService.ts`](backend/src/services/metadataService.ts) que busque en TODOS los proveedores y devuelva múltiples resultados.
+
+### 1.2 Nueva Función: `searchGames()`
+
+**Ubicación:** [`backend/src/services/metadataService.ts`](backend/src/services/metadataService.ts)
+
+```typescript
+export async function searchGames(opts: {
+ title: string;
+ platform?: string;
+ year?: number;
+}): Promise {
+ // Buscar en IGDB, RAWG y TheGamesDB simultáneamente
+ // Devolver todos los resultados encontrados
+ // Aplicar deduplicación basada en nombre + plataforma + año
+}
+```
+
+**Lógica de búsqueda:**
+
+1. Ejecutar búsquedas en paralelo en los tres proveedores
+2. Normalizar todos los resultados usando la función [`normalize()`](backend/src/services/metadataService.ts:12) existente
+3. Aplicar deduplicación para evitar duplicados entre proveedores
+4. Ordenar resultados por relevancia (coincidencia exacta primero)
+5. Limitar resultados (ej: 20 resultados máximos por proveedor, 50 total)
+
+**Deduplicación:**
+
+- Agrupar por nombre normalizado (lowercase, sin caracteres especiales)
+- Si hay duplicados, priorizar: IGDB > RAWG > TheGamesDB
+- Mantener `externalIds` de todas las fuentes para cada resultado
+
+### 1.3 Modificación del Endpoint
+
+**Ubicación:** [`backend/src/routes/metadata.ts`](backend/src/routes/metadata.ts)
+
+**Cambios:**
+
+1. Actualizar el esquema de validación para incluir `year` como parámetro opcional
+2. Llamar a `searchGames()` en lugar de `enrichGame()`
+3. Devolver el array de resultados directamente
+
+```typescript
+const searchMetadataSchema = z.object({
+ q: z.string().min(1, 'El parámetro de búsqueda es requerido'),
+ platform: z.string().optional(),
+ year: z.coerce.number().int().min(1900).max(2100).optional(),
+});
+
+app.get('/metadata/search', async (request, reply) => {
+ const validated = searchMetadataSchema.parse(request.query);
+ const results = await metadataService.searchGames({
+ title: validated.q,
+ platform: validated.platform,
+ year: validated.year,
+ });
+ return reply.code(200).send(results);
+});
+```
+
+### 1.4 Estructura de Respuesta de la API
+
+```typescript
+// GET /api/metadata/search?q=mario&year=1990&platform=NES
+
+[
+ {
+ source: 'igdb',
+ externalIds: {
+ igdb: 1234,
+ rawg: 5678,
+ thegamesdb: 9012,
+ },
+ name: 'Super Mario Bros.',
+ title: 'Super Mario Bros.',
+ slug: 'super-mario-bros',
+ releaseDate: '1985-09-13T00:00:00.000Z',
+ genres: ['Platform'],
+ coverUrl: 'https://example.com/cover.jpg',
+ platforms: [
+ {
+ id: 18,
+ name: 'Nintendo Entertainment System (NES)',
+ abbreviation: 'NES',
+ slug: 'nes',
+ },
+ ],
+ },
+ // ... más resultados
+];
+```
+
+### 1.5 Nuevo Endpoint para Guardar Resultado
+
+**Endpoint:** `POST /api/games/from-metadata`
+
+**Propósito:** Crear un juego a partir de un resultado de búsqueda de metadatos.
+
+**Body:**
+
+```typescript
+{
+ "metadata": {
+ "source": "igdb",
+ "externalIds": { "igdb": 1234 },
+ "name": "Super Mario Bros.",
+ "slug": "super-mario-bros",
+ "releaseDate": "1985-09-13T00:00:00.000Z",
+ "genres": ["Platform"],
+ "coverUrl": "https://example.com/cover.jpg"
+ },
+ "overrides": {
+ "platform": "NES",
+ "year": 1985,
+ "description": "Descripción personalizada..."
+ }
+}
+```
+
+**Lógica:**
+
+1. Mapear metadatos a estructura de [`Game`](backend/prisma/schema.prisma:12)
+2. Aplicar overrides si se proporcionan
+3. Guardar en base de datos usando [`GamesController.createGame()`](backend/src/controllers/gamesController.ts:73)
+4. Devolver el juego creado
+
+### 1.6 Diagrama de Flujo del Backend
+
+```mermaid
+flowchart TD
+ A[Cliente envía búsqueda] --> B[Validar parámetros]
+ B --> C[searchGames]
+ C --> D[Paralelo: IGDB]
+ C --> E[Paralelo: RAWG]
+ C --> F[Paralelo: TheGamesDB]
+ D --> G[Normalizar resultados]
+ E --> G
+ F --> G
+ G --> H[Deduplicar]
+ H --> I[Ordenar por relevancia]
+ I --> J[Limitar resultados]
+ J --> K[Devolver array]
+ K --> L[Cliente muestra resultados]
+ L --> M[Usuario selecciona resultado]
+ M --> N[POST /api/games/from-metadata]
+ N --> O[GamesController.createGame]
+ O --> P[Guardar en BD]
+ P --> Q[Devolver juego creado]
+```
+
+## 2. Arquitectura del Frontend
+
+### 2.1 Nueva Ruta
+
+**Ruta:** `/games/add` o `/games/search`
+
+**Archivo:** [`frontend/src/app/games/add/page.tsx`](frontend/src/app/games/add/page.tsx)
+
+**Razón:** `/games/add` es más semántico y sigue el patrón RESTful para crear recursos.
+
+### 2.2 Componentes a Crear
+
+#### 2.2.1 SearchForm Component
+
+**Ubicación:** [`frontend/src/components/games/SearchForm.tsx`](frontend/src/components/games/SearchForm.tsx)
+
+**Responsabilidades:**
+
+- Formulario de búsqueda con campos: título (obligatorio), año (opcional), plataforma (opcional)
+- Validación de formulario
+- Disparar evento de búsqueda
+
+**Componentes shadcn/ui a usar:**
+
+- [`Input`](frontend/src/components/ui/input.tsx) para título y año
+- [`Select`](frontend/src/components/ui/select.tsx) para plataforma
+- [`Button`](frontend/src/components/ui/button.tsx) para submit
+
+**Props:**
+
+```typescript
+interface SearchFormProps {
+ onSearch: (params: SearchParams) => void;
+ isLoading: boolean;
+}
+```
+
+#### 2.2.2 SearchResults Component
+
+**Ubicación:** [`frontend/src/components/games/SearchResults.tsx`](frontend/src/components/games/SearchResults.tsx)
+
+**Responsabilidades:**
+
+- Mostrar lista de resultados de búsqueda
+- Permitir selección de un resultado
+- Mostrar estado de carga
+
+**Componentes shadcn/ui a usar:**
+
+- [`Card`](frontend/src/components/ui/card.tsx) para cada resultado
+- [`Button`](frontend/src/components/ui/button.tsx) para seleccionar
+
+**Props:**
+
+```typescript
+interface SearchResultsProps {
+ results: MetadataGame[];
+ onSelect: (result: MetadataGame) => void;
+ isLoading: boolean;
+}
+```
+
+#### 2.2.3 GamePreviewDialog Component
+
+**Ubicación:** [`frontend/src/components/games/GamePreviewDialog.tsx`](frontend/src/components/games/GamePreviewDialog.tsx)
+
+**Responsabilidades:**
+
+- Mostrar detalles completos del resultado seleccionado
+- Permitir editar campos antes de guardar
+- Confirmar guardado
+
+**Componentes shadcn/ui a usar:**
+
+- [`Dialog`](frontend/src/components/ui/dialog.tsx) para el modal
+- [`Input`](frontend/src/components/ui/input.tsx) para campos editables
+- [`Textarea`](frontend/src/components/ui/textarea.tsx) para descripción
+- [`Button`](frontend/src/components/ui/button.tsx) para acciones
+
+**Props:**
+
+```typescript
+interface GamePreviewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ metadata: MetadataGame | null;
+ onSave: (metadata: MetadataGame, overrides: GameOverrides) => void;
+ isSaving: boolean;
+}
+```
+
+### 2.3 Flujo de Usuario
+
+```mermaid
+flowchart TD
+ A[Usuario accede a /games/add] --> B[SearchForm visible]
+ B --> C[Usuario ingresa título]
+ C --> D[Usuario hace clic en Buscar]
+ D --> E[LLAMADA API: GET /api/metadata/search]
+ E --> F[SearchResults muestra resultados]
+ F --> G[Usuario ve lista de juegos]
+ G --> H[Usuario hace clic en un resultado]
+ H --> I[GamePreviewDialog se abre]
+ I --> J[Usuario puede editar campos]
+ J --> K[Usuario hace clic en Guardar]
+ K --> L[LLAMADA API: POST /api/games/from-metadata]
+ L --> M{Éxito?}
+ M -->|Sí| N[Redirigir a /games o /games/id]
+ M -->|No| O[Mostrar error en dialog]
+ O --> J
+```
+
+### 2.4 Estructura de la Página
+
+```typescript
+// frontend/src/app/games/add/page.tsx
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { SearchForm } from '@/components/games/SearchForm';
+import { SearchResults } from '@/components/games/SearchResults';
+import { GamePreviewDialog } from '@/components/games/GamePreviewDialog';
+import { metadataApi, gamesApi } from '@/lib/api';
+
+export default function AddGamePage() {
+ const router = useRouter();
+ const [searchParams, setSearchParams] = useState(null);
+ const [results, setResults] = useState([]);
+ const [selectedResult, setSelectedResult] = useState(null);
+ const [isSearching, setIsSearching] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const handleSearch = async (params: SearchParams) => {
+ setIsSearching(true);
+ try {
+ const data = await metadataApi.search(params);
+ setResults(data);
+ setSearchParams(params);
+ } finally {
+ setIsSearching(false);
+ }
+ };
+
+ const handleSelect = (result: MetadataGame) => {
+ setSelectedResult(result);
+ };
+
+ const handleSave = async (metadata: MetadataGame, overrides: GameOverrides) => {
+ setIsSaving(true);
+ try {
+ const game = await gamesApi.createFromMetadata(metadata, overrides);
+ router.push(`/games/${game.id}`);
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+
+
Añadir Juego
+
+
+
+ {results.length > 0 && (
+
+ )}
+
+ !open && setSelectedResult(null)}
+ metadata={selectedResult}
+ onSave={handleSave}
+ isSaving={isSaving}
+ />
+
+ );
+}
+```
+
+### 2.5 Actualización del Cliente API
+
+**Ubicación:** [`frontend/src/lib/api.ts`](frontend/src/lib/api.ts)
+
+**Nuevas funciones:**
+
+```typescript
+// Búsqueda de metadatos
+export async function searchMetadata(params: {
+ q: string;
+ platform?: string;
+ year?: number;
+}): Promise {
+ const queryParams = new URLSearchParams({ q: params.q });
+ if (params.platform) queryParams.append('platform', params.platform);
+ if (params.year) queryParams.append('year', String(params.year));
+
+ const res = await fetch(`${API_BASE}/metadata/search?${queryParams}`);
+ if (!res.ok) throw new Error('Error searching metadata');
+ return res.json();
+}
+
+// Crear juego desde metadatos
+export async function createGameFromMetadata(
+ metadata: MetadataGame,
+ overrides?: GameOverrides
+): Promise {
+ const res = await fetch(`${API_BASE}/games/from-metadata`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ metadata, overrides }),
+ });
+ if (!res.ok) throw new Error('Error creating game from metadata');
+ return res.json();
+}
+```
+
+### 2.6 Integración con Página de Juegos
+
+**Ubicación:** [`frontend/src/app/games/page.tsx`](frontend/src/app/games/page.tsx)
+
+**Cambio:**
+
+- Reemplazar el botón "Nuevo Juego" que abre [`GameDialog`](frontend/src/components/games/GameDialog.tsx) por un enlace a `/games/add`
+- Mantener [`GameDialog`](frontend/src/components/games/GameDialog.tsx) solo para edición de juegos existentes
+
+```typescript
+// Antes
+
+
+// Después
+
+
+
+```
+
+## 3. Estrategia TDD (Test Driven Development)
+
+### 3.1 Orden de Implementación
+
+```mermaid
+flowchart LR
+ A[Tests Backend - searchGames] --> B[Implementar searchGames]
+ B --> C[Tests Backend - Endpoint /metadata/search]
+ C --> D[Implementar Endpoint]
+ D --> E[Tests Backend - POST /api/games/from-metadata]
+ E --> F[Implementar Endpoint from-metadata]
+ F --> G[Tests Frontend - Componentes Unitarios]
+ G --> H[Implementar Componentes]
+ H --> I[Tests E2E - Flujo Completo]
+ I --> J[Validar Integración]
+```
+
+### 3.2 Tests del Backend
+
+#### 3.2.1 Tests de `searchGames()`
+
+**Archivo:** [`backend/tests/services/metadataService.search.spec.ts`](backend/tests/services/metadataService.search.spec.ts)
+
+**Casos de prueba:**
+
+1. ✅ Debe devolver resultados de IGDB cuando hay coincidencias
+2. ✅ Debe devolver resultados de RAWG cuando IGDB falla
+3. ✅ Debe devolver resultados de TheGamesDB cuando IGDB y RAWG fallan
+4. ✅ Debe deduplicar resultados entre proveedores
+5. ✅ Debe priorizar IGDB sobre RAWG y TheGamesDB
+6. ✅ Debe filtrar por plataforma cuando se proporciona
+7. ✅ Debe filtrar por año cuando se proporciona
+8. ✅ Debe devolver array vacío cuando no hay resultados
+9. ✅ Debe manejar errores de API externas gracefully
+10. ✅ Debe limitar número de resultados
+
+**Ejemplo de test:**
+
+```typescript
+it('debe deduplicar resultados entre proveedores', async () => {
+ const mockIgdbResult = {
+ name: 'Super Mario Bros.',
+ source: 'igdb',
+ id: 1234,
+ // ... otros campos
+ };
+
+ const mockRawgResult = {
+ name: 'Super Mario Bros.',
+ source: 'rawg',
+ id: 5678,
+ // ... otros campos
+ };
+
+ vi.spyOn(igdb, 'searchGames').mockResolvedValue([mockIgdbResult]);
+ vi.spyOn(rawg, 'searchGames').mockResolvedValue([mockRawgResult]);
+
+ const results = await metadataService.searchGames({ title: 'mario' });
+
+ // Solo debería haber un resultado con ambos IDs externos
+ expect(results).toHaveLength(1);
+ expect(results[0].externalIds.igdb).toBe(1234);
+ expect(results[0].externalIds.rawg).toBe(5678);
+});
+```
+
+#### 3.2.2 Tests del Endpoint `/api/metadata/search`
+
+**Archivo:** [`backend/tests/routes/metadata.search.spec.ts`](backend/tests/routes/metadata.search.spec.ts)
+
+**Casos de prueba:**
+
+1. ✅ Debe devolver 200 con array de resultados
+2. ✅ Debe devolver 400 si falta parámetro `q`
+3. ✅ Debe devolver 400 si `year` no es válido
+4. ✅ Debe pasar parámetros a `searchGames()`
+5. ✅ Debe manejar errores del servicio
+
+#### 3.2.3 Tests del Endpoint `POST /api/games/from-metadata`
+
+**Archivo:** [`backend/tests/routes/games.from-metadata.spec.ts`](backend/tests/routes/games.from-metadata.spec.ts)
+
+**Casos de prueba:**
+
+1. ✅ Debe crear juego con metadatos de IGDB
+2. ✅ Debe crear juego con metadatos de RAWG
+3. ✅ Debe crear juego con metadatos de TheGamesDB
+4. ✅ Debe aplicar overrides cuando se proporcionan
+5. ✅ Debe guardar `externalIds` correctamente
+6. ✅ Debe devolver 400 si metadata es inválida
+7. ✅ Debe devolver 201 con juego creado
+
+### 3.3 Tests del Frontend
+
+#### 3.3.1 Tests Unitarios de Componentes
+
+**Archivo:** [`frontend/src/components/games/__tests__/SearchForm.test.tsx`](frontend/src/components/games/__tests__/SearchForm.test.tsx)
+
+**Casos de prueba:**
+
+1. ✅ Debe mostrar campos de formulario
+2. ✅ Debe validar que título es obligatorio
+3. ✅ Debe llamar `onSearch` con parámetros correctos
+4. ✅ Debe deshabilitar botón cuando `isLoading` es true
+
+**Archivo:** [`frontend/src/components/games/__tests__/SearchResults.test.tsx`](frontend/src/components/games/__tests__/SearchResults.test.tsx)
+
+**Casos de prueba:**
+
+1. ✅ Debe mostrar lista de resultados
+2. ✅ Debe mostrar mensaje cuando no hay resultados
+3. ✅ Debe llamar `onSelect` cuando se hace clic en resultado
+4. ✅ Debe mostrar estado de carga
+
+**Archivo:** [`frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx`](frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx)
+
+**Casos de prueba:**
+
+1. ✅ Debe mostrar metadatos del juego
+2. ✅ Debe permitir editar campos
+3. ✅ Debe llamar `onSave` con metadatos y overrides
+4. ✅ Debe cerrar cuando se cancela
+
+#### 3.3.2 Tests E2E
+
+**Archivo:** [`tests/e2e/game-search-flow.spec.ts`](tests/e2e/game-search-flow.spec.ts)
+
+**Casos de prueba:**
+
+1. ✅ Flujo completo: búsqueda → selección → guardado
+2. ✅ Búsqueda sin resultados
+3. ✅ Cancelar selección
+4. ✅ Editar campos antes de guardar
+5. ✅ Validación de errores
+
+**Ejemplo de test E2E:**
+
+```typescript
+test('flujo completo de búsqueda y guardado', async ({ page }) => {
+ await page.goto('/games/add');
+
+ // Ingresar búsqueda
+ await page.fill('input[name="title"]', 'Super Mario');
+ await page.click('button[type="submit"]');
+
+ // Esperar resultados
+ await page.waitForSelector('[data-testid="search-results"]');
+ const results = await page.locator('[data-testid="game-result"]').count();
+ expect(results).toBeGreaterThan(0);
+
+ // Seleccionar primer resultado
+ await page.click('[data-testid="game-result"]:first-child');
+
+ // Esperar dialog de preview
+ await page.waitForSelector('[data-testid="preview-dialog"]');
+
+ // Guardar
+ await page.click('[data-testid="save-button"]');
+
+ // Verificar redirección
+ await page.waitForURL(/\/games\/[a-z0-9]+$/);
+});
+```
+
+### 3.4 Orden de Implementación Recomendado
+
+1. **Sprint 1: Backend Core**
+ - Tests de `searchGames()`
+ - Implementar `searchGames()`
+ - Tests del endpoint `/api/metadata/search`
+ - Actualizar endpoint `/api/metadata/search`
+
+2. **Sprint 2: Backend Save**
+ - Tests del endpoint `POST /api/games/from-metadata`
+ - Implementar endpoint `POST /api/games/from-metadata`
+
+3. **Sprint 3: Frontend Components**
+ - Tests unitarios de `SearchForm`
+ - Implementar `SearchForm`
+ - Tests unitarios de `SearchResults`
+ - Implementar `SearchResults`
+ - Tests unitarios de `GamePreviewDialog`
+ - Implementar `GamePreviewDialog`
+
+4. **Sprint 4: Frontend Page & Integration**
+ - Implementar página `/games/add`
+ - Actualizar cliente API
+ - Integrar con página `/games`
+
+5. **Sprint 5: E2E & Polish**
+ - Tests E2E del flujo completo
+ - Corregir bugs encontrados
+ - Mejorar UX (loading states, error messages)
+
+## 4. Integración con Código Existente
+
+### 4.1 Código a Reutilizar
+
+#### Backend
+
+| Componente | Archivo | Uso |
+| ------------------------------------------------------------------------------- | ------------------------------------------------------------------ | --------------------------------- |
+| [`normalize()`](backend/src/services/metadataService.ts:12) | [`metadataService.ts`](backend/src/services/metadataService.ts) | Normalizar resultados de búsqueda |
+| [`igdb.searchGames()`](backend/src/services/igdbClient.ts:73) | [`igdbClient.ts`](backend/src/services/igdbClient.ts) | Buscar en IGDB |
+| [`rawg.searchGames()`](backend/src/services/rawgClient.ts:13) | [`rawgClient.ts`](backend/src/services/rawgClient.ts) | Buscar en RAWG |
+| [`thegamesdb.searchGames()`](backend/src/services/thegamesdbClient.ts:18) | [`thegamesdbClient.ts`](backend/src/services/thegamesdbClient.ts) | Buscar en TheGamesDB |
+| [`GamesController.createGame()`](backend/src/controllers/gamesController.ts:73) | [`gamesController.ts`](backend/src/controllers/gamesController.ts) | Crear juego en BD |
+| [`Game`](backend/prisma/schema.prisma:12) schema | [`schema.prisma`](backend/prisma/schema.prisma) | Modelo de datos |
+
+#### Frontend
+
+| Componente | Archivo | Uso |
+| ------------------------------------------------- | ----------------------------------------------------- | ---------------------- |
+| [`Button`](frontend/src/components/ui/button.tsx) | [`button.tsx`](frontend/src/components/ui/button.tsx) | Botones de acción |
+| [`Input`](frontend/src/components/ui/input.tsx) | [`input.tsx`](frontend/src/components/ui/input.tsx) | Campos de formulario |
+| [`Select`](frontend/src/components/ui/select.tsx) | [`select.tsx`](frontend/src/components/ui/select.tsx) | Selector de plataforma |
+| [`Card`](frontend/src/components/ui/card.tsx) | [`card.tsx`](frontend/src/components/ui/card.tsx) | Tarjetas de resultados |
+| [`Dialog`](frontend/src/components/ui/dialog.tsx) | [`dialog.tsx`](frontend/src/components/ui/dialog.tsx) | Dialog de preview |
+| [`MetadataGame`](frontend/src/lib/api.ts:39) type | [`api.ts`](frontend/src/lib/api.ts) | Tipo de metadatos |
+| [`Game`](frontend/src/lib/api.ts:57) type | [`api.ts`](frontend/src/lib/api.ts) | Tipo de juego |
+
+### 4.2 Código a Modificar
+
+#### Backend
+
+| Archivo | Cambio |
+| --------------------------------------------------------------- | ------------------------------------------------ |
+| [`metadataService.ts`](backend/src/services/metadataService.ts) | Agregar función `searchGames()` |
+| [`metadata.ts`](backend/src/routes/metadata.ts) | Actualizar endpoint `/api/metadata/search` |
+| [`games.ts`](backend/src/routes/games.ts) | Agregar endpoint `POST /api/games/from-metadata` |
+| [`types/index.ts`](backend/src/types/index.ts) | Agregar tipos para búsqueda múltiple |
+
+#### Frontend
+
+| Archivo | Cambio |
+| ---------------------------------------------------------------- | ----------------------------------------------------------------- |
+| [`api.ts`](frontend/src/lib/api.ts) | Agregar funciones `searchMetadata()` y `createGameFromMetadata()` |
+| [`games/page.tsx`](frontend/src/app/games/page.tsx) | Cambiar botón "Nuevo Juego" a enlace `/games/add` |
+| [`GameDialog.tsx`](frontend/src/components/games/GameDialog.tsx) | Mantener solo para edición (opcional) |
+
+### 4.3 Código Nuevo a Crear
+
+#### Backend
+
+| Archivo | Propósito |
+| ---------------------------------------------------------------------------------------------------------------- | -------------------------------- |
+| [`backend/tests/services/metadataService.search.spec.ts`](backend/tests/services/metadataService.search.spec.ts) | Tests de `searchGames()` |
+| [`backend/tests/routes/games.from-metadata.spec.ts`](backend/tests/routes/games.from-metadata.spec.ts) | Tests del endpoint from-metadata |
+
+#### Frontend
+
+| Archivo | Propósito |
+| ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------- |
+| [`frontend/src/app/games/add/page.tsx`](frontend/src/app/games/add/page.tsx) | Página de búsqueda de juegos |
+| [`frontend/src/components/games/SearchForm.tsx`](frontend/src/components/games/SearchForm.tsx) | Formulario de búsqueda |
+| [`frontend/src/components/games/SearchResults.tsx`](frontend/src/components/games/SearchResults.tsx) | Lista de resultados |
+| [`frontend/src/components/games/GamePreviewDialog.tsx`](frontend/src/components/games/GamePreviewDialog.tsx) | Dialog de preview y edición |
+| [`frontend/src/components/games/__tests__/SearchForm.test.tsx`](frontend/src/components/games/__tests__/SearchForm.test.tsx) | Tests de SearchForm |
+| [`frontend/src/components/games/__tests__/SearchResults.test.tsx`](frontend/src/components/games/__tests__/SearchResults.test.tsx) | Tests de SearchResults |
+| [`frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx`](frontend/src/components/games/__tests__/GamePreviewDialog.test.tsx) | Tests de GamePreviewDialog |
+| [`tests/e2e/game-search-flow.spec.ts`](tests/e2e/game-search-flow.spec.ts) | Tests E2E del flujo |
+
+## 5. Diagrama de Arquitectura Completa
+
+```mermaid
+graph TB
+ subgraph Frontend
+ A[Página /games/add] --> B[SearchForm]
+ A --> C[SearchResults]
+ A --> D[GamePreviewDialog]
+ B --> E[api.ts - searchMetadata]
+ C --> F[api.ts - createGameFromMetadata]
+ D --> F
+ end
+
+ subgraph Backend
+ E --> G[GET /api/metadata/search]
+ F --> H[POST /api/games/from-metadata]
+ G --> I[metadataService.searchGames]
+ I --> J[igdbClient.searchGames]
+ I --> K[rawgClient.searchGames]
+ I --> L[thegamesdbClient.searchGames]
+ H --> M[GamesController.createGame]
+ M --> N[(Prisma - Game)]
+ end
+
+ subgraph External APIs
+ J --> O[IGDB API]
+ K --> P[RAWG API]
+ L --> Q[TheGamesDB API]
+ end
+
+ style Frontend fill:#e1f5fe
+ style Backend fill:#f3e5f5
+ style External APIs fill:#fff3e0
+```
+
+## 6. Consideraciones Adicionales
+
+### 6.1 Manejo de Errores
+
+**Backend:**
+
+- Capturar errores de APIs externas sin fallar completamente
+- Devolver resultados parciales si algún proveedor falla
+- Logging de errores para debugging
+
+**Frontend:**
+
+- Mostrar mensajes de error claros al usuario
+- Permitir reintentar búsqueda
+- Mostrar estado de carga durante búsquedas
+
+### 6.2 Performance
+
+**Backend:**
+
+- Usar `Promise.all()` para búsquedas paralelas
+- Implementar caching de resultados (opcional)
+- Limitar número de resultados por proveedor
+
+**Frontend:**
+
+- Implementar debounce en búsqueda
+- Lazy loading de imágenes de portada
+- Paginación de resultados si hay muchos
+
+### 6.3 Seguridad
+
+**Backend:**
+
+- Validar todos los parámetros de entrada
+- Sanitizar datos antes de guardar en BD
+- Rate limiting en endpoints de búsqueda
+
+**Frontend:**
+
+- Sanitizar HTML antes de mostrar
+- Validar en cliente y servidor
+
+### 6.4 Accesibilidad
+
+**Frontend:**
+
+- Labels en todos los campos de formulario
+- Focus management en dialogs
+- Keyboard navigation
+- ARIA labels donde sea necesario
+
+### 6.5 UX Considerations
+
+- Mostrar loading states claros
+- Feedback inmediato al usuario
+- Permitir cancelar búsqueda en progreso
+- Mostrar fuente de cada resultado (IGDB, RAWG, etc.)
+- Permitir ver más detalles antes de seleccionar
+- Confirmación antes de guardar
+
+## 7. Resumen de Cambios
+
+### Backend
+
+| Archivo | Acción |
+| ----------------------------------------------------------------------------------------- | ------------------------------------------------ |
+| [`metadataService.ts`](backend/src/services/metadataService.ts) | Agregar `searchGames()` |
+| [`metadata.ts`](backend/src/routes/metadata.ts) | Modificar endpoint `/api/metadata/search` |
+| [`games.ts`](backend/src/routes/games.ts) | Agregar endpoint `POST /api/games/from-metadata` |
+| [`types/index.ts`](backend/src/types/index.ts) | Agregar tipos nuevos |
+| [`metadataService.search.spec.ts`](backend/tests/services/metadataService.search.spec.ts) | **NUEVO** |
+| [`games.from-metadata.spec.ts`](backend/tests/routes/games.from-metadata.spec.ts) | **NUEVO** |
+
+### Frontend
+
+| Archivo | Acción |
+| ------------------------------------------------------------------------------ | ------------------------------------------------------- |
+| [`api.ts`](frontend/src/lib/api.ts) | Agregar `searchMetadata()` y `createGameFromMetadata()` |
+| [`games/page.tsx`](frontend/src/app/games/page.tsx) | Cambiar botón a enlace |
+| [`games/add/page.tsx`](frontend/src/app/games/add/page.tsx) | **NUEVO** |
+| [`SearchForm.tsx`](frontend/src/components/games/SearchForm.tsx) | **NUEVO** |
+| [`SearchResults.tsx`](frontend/src/components/games/SearchResults.tsx) | **NUEVO** |
+| [`GamePreviewDialog.tsx`](frontend/src/components/games/GamePreviewDialog.tsx) | **NUEVO** |
+| Tests unitarios | **NUEVOS** |
+| [`game-search-flow.spec.ts`](tests/e2e/game-search-flow.spec.ts) | **NUEVO** |
+
+---
+
+**Documento creado:** 2025-03-21
+**Autor:** Architect Mode
+**Estado:** Diseño completo listo para implementación
diff --git a/yarn.lock b/yarn.lock
index 1b6a88f..ea3323f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -11,6 +11,13 @@ __metadata:
languageName: unknown
linkType: soft
+"@adobe/css-tools@npm:^4.4.0":
+ version: 4.4.4
+ resolution: "@adobe/css-tools@npm:4.4.4"
+ checksum: 10c0/8f3e6cfaa5e6286e6f05de01d91d060425be2ebaef490881f5fe6da8bbdb336835c5d373ea337b0c3b0a1af4be048ba18780f0f6021d30809b4545922a7e13d9
+ languageName: node
+ linkType: hard
+
"@alloc/quick-lru@npm:^5.2.0":
version: 5.2.0
resolution: "@alloc/quick-lru@npm:5.2.0"
@@ -38,7 +45,40 @@ __metadata:
languageName: node
linkType: hard
-"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
+"@asamuzakjp/css-color@npm:^5.0.1":
+ version: 5.0.1
+ resolution: "@asamuzakjp/css-color@npm:5.0.1"
+ dependencies:
+ "@csstools/css-calc": "npm:^3.1.1"
+ "@csstools/css-color-parser": "npm:^4.0.2"
+ "@csstools/css-parser-algorithms": "npm:^4.0.0"
+ "@csstools/css-tokenizer": "npm:^4.0.0"
+ lru-cache: "npm:^11.2.6"
+ checksum: 10c0/3e8d74a3b7f3005a325cb8e7f3da1aa32aeac4cd9ce387826dc25b16eaab4dc0e4a6faded8ccc1895959141f4a4a70e8bc38723347b89667b7b224990d16683c
+ languageName: node
+ linkType: hard
+
+"@asamuzakjp/dom-selector@npm:^7.0.3":
+ version: 7.0.4
+ resolution: "@asamuzakjp/dom-selector@npm:7.0.4"
+ dependencies:
+ "@asamuzakjp/nwsapi": "npm:^2.3.9"
+ bidi-js: "npm:^1.0.3"
+ css-tree: "npm:^3.2.1"
+ is-potential-custom-element-name: "npm:^1.0.1"
+ lru-cache: "npm:^11.2.7"
+ checksum: 10c0/6539422595ed445f182eda78554fd339ff06f6d5add0520ee7cc9715baeb361454c11ffe21a6e7461907182153f88852866c4ea12158b9c9202ccc44767bc749
+ languageName: node
+ linkType: hard
+
+"@asamuzakjp/nwsapi@npm:^2.3.9":
+ version: 2.3.9
+ resolution: "@asamuzakjp/nwsapi@npm:2.3.9"
+ checksum: 10c0/869b81382e775499c96c45c6dbe0d0766a6da04bcf0abb79f5333535c4e19946851acaa43398f896e2ecc5a1de9cf3db7cf8c4b1afac1ee3d15e21584546d74d
+ languageName: node
+ linkType: hard
+
+"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0":
version: 7.29.0
resolution: "@babel/code-frame@npm:7.29.0"
dependencies:
@@ -316,6 +356,13 @@ __metadata:
languageName: node
linkType: hard
+"@babel/runtime@npm:^7.12.5":
+ version: 7.29.2
+ resolution: "@babel/runtime@npm:7.29.2"
+ checksum: 10c0/30b80a0140d16467792e1bbeb06f655b0dab70407da38dfac7fedae9c859f9ae9d846ef14ad77bd3814c064295fe9b1bc551f1541ea14646ae9f22b71a8bc17a
+ languageName: node
+ linkType: hard
+
"@babel/template@npm:^7.28.6":
version: 7.28.6
resolution: "@babel/template@npm:7.28.6"
@@ -352,6 +399,24 @@ __metadata:
languageName: node
linkType: hard
+"@bcoe/v8-coverage@npm:^1.0.2":
+ version: 1.0.2
+ resolution: "@bcoe/v8-coverage@npm:1.0.2"
+ checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3
+ languageName: node
+ linkType: hard
+
+"@bramus/specificity@npm:^2.4.2":
+ version: 2.4.2
+ resolution: "@bramus/specificity@npm:2.4.2"
+ dependencies:
+ css-tree: "npm:^3.0.0"
+ bin:
+ specificity: bin/cli.js
+ checksum: 10c0/c5f4e04e0bca0d2202598207a5eb0733c8109d12a68a329caa26373bec598d99db5bb785b8865fefa00fc01b08c6068138807ceb11a948fe15e904ed6cf4ba72
+ languageName: node
+ linkType: hard
+
"@cspotcode/source-map-support@npm:^0.8.0":
version: 0.8.1
resolution: "@cspotcode/source-map-support@npm:0.8.1"
@@ -361,6 +426,64 @@ __metadata:
languageName: node
linkType: hard
+"@csstools/color-helpers@npm:^6.0.2":
+ version: 6.0.2
+ resolution: "@csstools/color-helpers@npm:6.0.2"
+ checksum: 10c0/4c66574563d7c960010c11e41c2673675baff07c427cca6e8dddffa5777de45770d13ff3efce1c0642798089ad55de52870d9d8141f78db3fa5bba012f2d3789
+ languageName: node
+ linkType: hard
+
+"@csstools/css-calc@npm:^3.1.1":
+ version: 3.1.1
+ resolution: "@csstools/css-calc@npm:3.1.1"
+ peerDependencies:
+ "@csstools/css-parser-algorithms": ^4.0.0
+ "@csstools/css-tokenizer": ^4.0.0
+ checksum: 10c0/6efcc016d988edf66e54c7bad03e352d61752cbd1b56c7557fd013868aab23505052ded8f912cd4034e216943ea1e04c957d81012489e3eddc14a57b386510ef
+ languageName: node
+ linkType: hard
+
+"@csstools/css-color-parser@npm:^4.0.2":
+ version: 4.0.2
+ resolution: "@csstools/css-color-parser@npm:4.0.2"
+ dependencies:
+ "@csstools/color-helpers": "npm:^6.0.2"
+ "@csstools/css-calc": "npm:^3.1.1"
+ peerDependencies:
+ "@csstools/css-parser-algorithms": ^4.0.0
+ "@csstools/css-tokenizer": ^4.0.0
+ checksum: 10c0/487cf507ef4630f74bd67d84298294ed269900b206ade015a968d20047e07ff46f235b72e26fe0c6b949a03f8f9f00a22c363da49c1b06ca60b32d0188e546be
+ languageName: node
+ linkType: hard
+
+"@csstools/css-parser-algorithms@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "@csstools/css-parser-algorithms@npm:4.0.0"
+ peerDependencies:
+ "@csstools/css-tokenizer": ^4.0.0
+ checksum: 10c0/94558c2428d6ef0ddef542e86e0a8376aa1263a12a59770abb13ba50d7b83086822c75433f32aa2e7fef00555e1cc88292f9ca5bce79aed232bb3fed73b1528d
+ languageName: node
+ linkType: hard
+
+"@csstools/css-syntax-patches-for-csstree@npm:^1.1.1":
+ version: 1.1.1
+ resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.1"
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+ checksum: 10c0/947f82e9e8af0512f1d6600f68da1bbe8d15112fa73435169608a68dcf20262ae517c799202c86a6c3bc889d0e9fab724ad5661a3aa98432390f8f9765b86ddc
+ languageName: node
+ linkType: hard
+
+"@csstools/css-tokenizer@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "@csstools/css-tokenizer@npm:4.0.0"
+ checksum: 10c0/669cf3d0f9c8e1ffdf8c9955ad8beba0c8cfe03197fe29a4fcbd9ee6f7a18856cfa42c62670021a75183d9ab37f5d14a866e6a9df753a6c07f59e36797a9ea9f
+ languageName: node
+ linkType: hard
+
"@dotenvx/dotenvx@npm:^1.48.4":
version: 1.52.0
resolution: "@dotenvx/dotenvx@npm:1.52.0"
@@ -890,6 +1013,18 @@ __metadata:
languageName: node
linkType: hard
+"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0":
+ version: 1.15.0
+ resolution: "@exodus/bytes@npm:1.15.0"
+ peerDependencies:
+ "@noble/hashes": ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ "@noble/hashes":
+ optional: true
+ checksum: 10c0/b48aad9729653385d6ed055c28cfcf0b1b1481cf5d83f4375c12abd7988f1d20f69c80b5f95d4a1cc24d9abe32b9efc352a812d53884c26efea172aca8b6356d
+ languageName: node
+ linkType: hard
+
"@fastify/ajv-compiler@npm:^3.5.0":
version: 3.6.0
resolution: "@fastify/ajv-compiler@npm:3.6.0"
@@ -1472,7 +1607,7 @@ __metadata:
languageName: node
linkType: hard
-"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28":
+"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28, @jridgewell/trace-mapping@npm:^0.3.31":
version: 0.3.31
resolution: "@jridgewell/trace-mapping@npm:0.3.31"
dependencies:
@@ -1733,6 +1868,13 @@ __metadata:
languageName: node
linkType: hard
+"@oxc-project/types@npm:=0.120.0":
+ version: 0.120.0
+ resolution: "@oxc-project/types@npm:0.120.0"
+ checksum: 10c0/3090ca95ed1467ae790a79cf7aa49d1ea4ac390dbfccb7afb914c138034d01e72115e2e137a3cc76f409ba424e4d2b160a599fe137c88033ad68ba2df1e40b29
+ languageName: node
+ linkType: hard
+
"@pinojs/redact@npm:^0.4.0":
version: 0.4.0
resolution: "@pinojs/redact@npm:0.4.0"
@@ -1758,6 +1900,13 @@ __metadata:
languageName: node
linkType: hard
+"@polka/url@npm:^1.0.0-next.24":
+ version: 1.0.0-next.29
+ resolution: "@polka/url@npm:1.0.0-next.29"
+ checksum: 10c0/0d58e081844095cb029d3c19a659bfefd09d5d51a2f791bc61eba7ea826f13d6ee204a8a448c2f5a855c17df07b37517373ff916dd05801063c0568ae9937684
+ languageName: node
+ linkType: hard
+
"@prisma/client@npm:6.19.2":
version: 6.19.2
resolution: "@prisma/client@npm:6.19.2"
@@ -3126,6 +3275,127 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-android-arm64@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-android-arm64@npm:1.0.0-rc.10"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-darwin-arm64@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-darwin-arm64@npm:1.0.0-rc.10"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-darwin-x64@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-darwin-x64@npm:1.0.0-rc.10"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-freebsd-x64@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-freebsd-x64@npm:1.0.0-rc.10"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-arm64-musl@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=ppc64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-x64-gnu@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-linux-x64-musl@npm:1.0.0-rc.10"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-openharmony-arm64@npm:1.0.0-rc.10"
+ conditions: os=openharmony & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-wasm32-wasi@npm:1.0.0-rc.10"
+ dependencies:
+ "@napi-rs/wasm-runtime": "npm:^1.1.1"
+ conditions: cpu=wasm32
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.0.0-rc.10"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/binding-win32-x64-msvc@npm:1.0.0-rc.10"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/pluginutils@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "@rolldown/pluginutils@npm:1.0.0-rc.10"
+ checksum: 10c0/7478f982d2705fef5f844e714aa264571d30368ef90883642fdc9eb869613c0c3060e8a8f69255e37a6fb600cbe4be35ce273d1f808fa6fe2a4b4e72116caf29
+ languageName: node
+ linkType: hard
+
+"@rolldown/pluginutils@npm:1.0.0-rc.7":
+ version: 1.0.0-rc.7
+ resolution: "@rolldown/pluginutils@npm:1.0.0-rc.7"
+ checksum: 10c0/9d5490b5805b25bcd1720ca01c4c032b55a0ef953dab36a8dd42c568e82214576baa464f3027cd5dff3fabcfbe3bf3db2251d12b60220f5d1cd2ffde5ee37082
+ languageName: node
+ linkType: hard
+
"@rollup/rollup-android-arm-eabi@npm:4.57.1":
version: 4.57.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.57.1"
@@ -3329,7 +3599,7 @@ __metadata:
languageName: node
linkType: hard
-"@standard-schema/spec@npm:^1.0.0":
+"@standard-schema/spec@npm:^1.0.0, @standard-schema/spec@npm:^1.1.0":
version: 1.1.0
resolution: "@standard-schema/spec@npm:1.1.0"
checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
@@ -3516,6 +3786,65 @@ __metadata:
languageName: node
linkType: hard
+"@testing-library/dom@npm:^10.4.1":
+ version: 10.4.1
+ resolution: "@testing-library/dom@npm:10.4.1"
+ dependencies:
+ "@babel/code-frame": "npm:^7.10.4"
+ "@babel/runtime": "npm:^7.12.5"
+ "@types/aria-query": "npm:^5.0.1"
+ aria-query: "npm:5.3.0"
+ dom-accessibility-api: "npm:^0.5.9"
+ lz-string: "npm:^1.5.0"
+ picocolors: "npm:1.1.1"
+ pretty-format: "npm:^27.0.2"
+ checksum: 10c0/19ce048012d395ad0468b0dbcc4d0911f6f9e39464d7a8464a587b29707eed5482000dad728f5acc4ed314d2f4d54f34982999a114d2404f36d048278db815b1
+ languageName: node
+ linkType: hard
+
+"@testing-library/jest-dom@npm:^6.9.1":
+ version: 6.9.1
+ resolution: "@testing-library/jest-dom@npm:6.9.1"
+ dependencies:
+ "@adobe/css-tools": "npm:^4.4.0"
+ aria-query: "npm:^5.0.0"
+ css.escape: "npm:^1.5.1"
+ dom-accessibility-api: "npm:^0.6.3"
+ picocolors: "npm:^1.1.1"
+ redent: "npm:^3.0.0"
+ checksum: 10c0/4291ebd2f0f38d14cefac142c56c337941775a5807e2a3d6f1a14c2fbd6be76a18e498ed189e95bedc97d9e8cf1738049bc76c85b5bc5e23fae7c9e10f7b3a12
+ languageName: node
+ linkType: hard
+
+"@testing-library/react@npm:^16.3.2":
+ version: 16.3.2
+ resolution: "@testing-library/react@npm:16.3.2"
+ dependencies:
+ "@babel/runtime": "npm:^7.12.5"
+ peerDependencies:
+ "@testing-library/dom": ^10.0.0
+ "@types/react": ^18.0.0 || ^19.0.0
+ "@types/react-dom": ^18.0.0 || ^19.0.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ "@types/react":
+ optional: true
+ "@types/react-dom":
+ optional: true
+ checksum: 10c0/f9c7f0915e1b5f7b750e6c7d8b51f091b8ae7ea99bacb761d7b8505ba25de9cfcb749a0f779f1650fb268b499dd79165dc7e1ee0b8b4cb63430d3ddc81ffe044
+ languageName: node
+ linkType: hard
+
+"@testing-library/user-event@npm:^14.6.1":
+ version: 14.6.1
+ resolution: "@testing-library/user-event@npm:14.6.1"
+ peerDependencies:
+ "@testing-library/dom": ">=7.21.4"
+ checksum: 10c0/75fea130a52bf320d35d46ed54f3eec77e71a56911b8b69a3fe29497b0b9947b2dc80d30f04054ad4ce7f577856ae3e5397ea7dff0ef14944d3909784c7a93fe
+ languageName: node
+ linkType: hard
+
"@ts-morph/common@npm:~0.27.0":
version: 0.27.0
resolution: "@ts-morph/common@npm:0.27.0"
@@ -3564,6 +3893,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/aria-query@npm:^5.0.1":
+ version: 5.0.4
+ resolution: "@types/aria-query@npm:5.0.4"
+ checksum: 10c0/dc667bc6a3acc7bba2bccf8c23d56cb1f2f4defaa704cfef595437107efaa972d3b3db9ec1d66bc2711bfc35086821edd32c302bffab36f2e79b97f312069f08
+ languageName: node
+ linkType: hard
+
"@types/chai-subset@npm:^1.3.3":
version: 1.3.6
resolution: "@types/chai-subset@npm:1.3.6"
@@ -3580,6 +3916,23 @@ __metadata:
languageName: node
linkType: hard
+"@types/chai@npm:^5.2.2":
+ version: 5.2.3
+ resolution: "@types/chai@npm:5.2.3"
+ dependencies:
+ "@types/deep-eql": "npm:*"
+ assertion-error: "npm:^2.0.1"
+ checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f
+ languageName: node
+ linkType: hard
+
+"@types/deep-eql@npm:*":
+ version: 4.0.2
+ resolution: "@types/deep-eql@npm:4.0.2"
+ checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
+ languageName: node
+ linkType: hard
+
"@types/esrecurse@npm:^4.3.1":
version: 4.3.1
resolution: "@types/esrecurse@npm:4.3.1"
@@ -3587,7 +3940,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8":
+"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.0, @types/estree@npm:^1.0.6, @types/estree@npm:^1.0.8":
version: 1.0.8
resolution: "@types/estree@npm:1.0.8"
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
@@ -4102,6 +4455,48 @@ __metadata:
languageName: node
linkType: hard
+"@vitejs/plugin-react@npm:^6.0.1":
+ version: 6.0.1
+ resolution: "@vitejs/plugin-react@npm:6.0.1"
+ dependencies:
+ "@rolldown/pluginutils": "npm:1.0.0-rc.7"
+ peerDependencies:
+ "@rolldown/plugin-babel": ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ "@rolldown/plugin-babel":
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+ checksum: 10c0/6c42f53a970cb6b0776ba5b4203bb01690ac564c56fca706d4037b50aec965ddc0f11530ab58ab2cd0fbe8c12e14cff6966b22d90391283b4a53294e3ddd478d
+ languageName: node
+ linkType: hard
+
+"@vitest/coverage-v8@npm:^4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/coverage-v8@npm:4.1.0"
+ dependencies:
+ "@bcoe/v8-coverage": "npm:^1.0.2"
+ "@vitest/utils": "npm:4.1.0"
+ ast-v8-to-istanbul: "npm:^1.0.0"
+ istanbul-lib-coverage: "npm:^3.2.2"
+ istanbul-lib-report: "npm:^3.0.1"
+ istanbul-reports: "npm:^3.2.0"
+ magicast: "npm:^0.5.2"
+ obug: "npm:^2.1.1"
+ std-env: "npm:^4.0.0-rc.1"
+ tinyrainbow: "npm:^3.0.3"
+ peerDependencies:
+ "@vitest/browser": 4.1.0
+ vitest: 4.1.0
+ peerDependenciesMeta:
+ "@vitest/browser":
+ optional: true
+ checksum: 10c0/0bcbc9d20dd4c998ff76b82a721d6000f1300346b93cfc441f9012797a34be65bb73dc99451275d7f7dcb06b98856b4e5dc30b2c483051ec2320e9a89af14179
+ languageName: node
+ linkType: hard
+
"@vitest/expect@npm:0.31.4":
version: 0.31.4
resolution: "@vitest/expect@npm:0.31.4"
@@ -4124,6 +4519,48 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/expect@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/expect@npm:4.1.0"
+ dependencies:
+ "@standard-schema/spec": "npm:^1.1.0"
+ "@types/chai": "npm:^5.2.2"
+ "@vitest/spy": "npm:4.1.0"
+ "@vitest/utils": "npm:4.1.0"
+ chai: "npm:^6.2.2"
+ tinyrainbow: "npm:^3.0.3"
+ checksum: 10c0/91cd7bb036401df5dfd9204f3de9a0afdb21dea6ee154622e5ed849e87a0df68b74258d490559c7046d3c03bc7aa634e9b0c166942a21d5e475c86c971486091
+ languageName: node
+ linkType: hard
+
+"@vitest/mocker@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/mocker@npm:4.1.0"
+ dependencies:
+ "@vitest/spy": "npm:4.1.0"
+ estree-walker: "npm:^3.0.3"
+ magic-string: "npm:^0.30.21"
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+ checksum: 10c0/f61d3df6461008eb1e62ba465172207b29bd0d9866ff6bc88cd40fc99cd5d215ad89e2894ba6de87068e33f75de903b28a65ccc6074edf3de1fbead6a4a369cc
+ languageName: node
+ linkType: hard
+
+"@vitest/pretty-format@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/pretty-format@npm:4.1.0"
+ dependencies:
+ tinyrainbow: "npm:^3.0.3"
+ checksum: 10c0/638077f53b5f24ff2d4bc062e69931fa718141db28ddafe435de98a402586b82e8c3cadfc580c0ad233d7f0203aa22d866ac2adca98b83038dbd5423c3d7fe27
+ languageName: node
+ linkType: hard
+
"@vitest/runner@npm:0.31.4":
version: 0.31.4
resolution: "@vitest/runner@npm:0.31.4"
@@ -4147,6 +4584,16 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/runner@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/runner@npm:4.1.0"
+ dependencies:
+ "@vitest/utils": "npm:4.1.0"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/9e09ca1b9070d3fe26c9bd48443d21b9fe2cb9abb2f694300bd9e5065f4e904f7322c07cd4bafadfed6fb11adfb50e4d1535f327ac6d24b6c373e92be90510bc
+ languageName: node
+ linkType: hard
+
"@vitest/snapshot@npm:0.31.4":
version: 0.31.4
resolution: "@vitest/snapshot@npm:0.31.4"
@@ -4169,6 +4616,18 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/snapshot@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/snapshot@npm:4.1.0"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.0"
+ "@vitest/utils": "npm:4.1.0"
+ magic-string: "npm:^0.30.21"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/582c22988c47a99d93dd17ef660427fefe101f67ae4394b64fe58ec103ddc55fc5993626b4a2b556e0a38d40552abaca78196907455e794805ba197b3d56860f
+ languageName: node
+ linkType: hard
+
"@vitest/spy@npm:0.31.4":
version: 0.31.4
resolution: "@vitest/spy@npm:0.31.4"
@@ -4187,6 +4646,30 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/spy@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/spy@npm:4.1.0"
+ checksum: 10c0/363776bbffda45af76ff500deacb9b1a35ad8b889462c1be9ebe5f29578ce1dd2c4bd7858c8188614a7db9699a5c802b7beb72e5a18ab5130a70326817961446
+ languageName: node
+ linkType: hard
+
+"@vitest/ui@npm:^4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/ui@npm:4.1.0"
+ dependencies:
+ "@vitest/utils": "npm:4.1.0"
+ fflate: "npm:^0.8.2"
+ flatted: "npm:3.4.0"
+ pathe: "npm:^2.0.3"
+ sirv: "npm:^3.0.2"
+ tinyglobby: "npm:^0.2.15"
+ tinyrainbow: "npm:^3.0.3"
+ peerDependencies:
+ vitest: 4.1.0
+ checksum: 10c0/3629aadc120b992c80a18c32879358a40d936245ab987f64bd76cf6b13abb319b2ef9a029be69be7f6ea7f7ae9182e54f6d631fd57df32ba31060d6ae488048e
+ languageName: node
+ linkType: hard
+
"@vitest/utils@npm:0.31.4":
version: 0.31.4
resolution: "@vitest/utils@npm:0.31.4"
@@ -4209,6 +4692,17 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/utils@npm:4.1.0":
+ version: 4.1.0
+ resolution: "@vitest/utils@npm:4.1.0"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.0"
+ convert-source-map: "npm:^2.0.0"
+ tinyrainbow: "npm:^3.0.3"
+ checksum: 10c0/222afbdef4f680a554bb6c3d946a4a879a441ebfb8597295cb7554d295e0e2624f3d4c2920b5767bbb8961a9f8a16756270ffc84032f5ea432cdce080ccab050
+ languageName: node
+ linkType: hard
+
"abbrev@npm:^4.0.0":
version: 4.0.0
resolution: "abbrev@npm:4.0.0"
@@ -4410,7 +4904,16 @@ __metadata:
languageName: node
linkType: hard
-"aria-query@npm:^5.3.2":
+"aria-query@npm:5.3.0":
+ version: 5.3.0
+ resolution: "aria-query@npm:5.3.0"
+ dependencies:
+ dequal: "npm:^2.0.3"
+ checksum: 10c0/2bff0d4eba5852a9dd578ecf47eaef0e82cc52569b48469b0aac2db5145db0b17b7a58d9e01237706d1e14b7a1b0ac9b78e9c97027ad97679dd8f91b85da1469
+ languageName: node
+ linkType: hard
+
+"aria-query@npm:^5.0.0, aria-query@npm:^5.3.2":
version: 5.3.2
resolution: "aria-query@npm:5.3.2"
checksum: 10c0/003c7e3e2cff5540bf7a7893775fc614de82b0c5dde8ae823d47b7a28a9d4da1f7ed85f340bdb93d5649caa927755f0e31ecc7ab63edfdfc00c8ef07e505e03e
@@ -4531,6 +5034,13 @@ __metadata:
languageName: node
linkType: hard
+"assertion-error@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "assertion-error@npm:2.0.1"
+ checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8
+ languageName: node
+ linkType: hard
+
"ast-types-flow@npm:^0.0.8":
version: 0.0.8
resolution: "ast-types-flow@npm:0.0.8"
@@ -4547,6 +5057,17 @@ __metadata:
languageName: node
linkType: hard
+"ast-v8-to-istanbul@npm:^1.0.0":
+ version: 1.0.0
+ resolution: "ast-v8-to-istanbul@npm:1.0.0"
+ dependencies:
+ "@jridgewell/trace-mapping": "npm:^0.3.31"
+ estree-walker: "npm:^3.0.3"
+ js-tokens: "npm:^10.0.0"
+ checksum: 10c0/35e57b754ba63287358094d4f7ae8de2de27286fb4e76a1fbf28b2e67e3b670b59c3f511882473d0fd2cdbaa260062e3cd4f216b724c70032e2b09e5cebbd618
+ languageName: node
+ linkType: hard
+
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
@@ -4640,6 +5161,15 @@ __metadata:
languageName: node
linkType: hard
+"bidi-js@npm:^1.0.3":
+ version: 1.0.3
+ resolution: "bidi-js@npm:1.0.3"
+ dependencies:
+ require-from-string: "npm:^2.0.2"
+ checksum: 10c0/fdddea4aa4120a34285486f2267526cd9298b6e8b773ad25e765d4f104b6d7437ab4ba542e6939e3ac834a7570bcf121ee2cf6d3ae7cd7082c4b5bedc8f271e1
+ languageName: node
+ linkType: hard
+
"binary-extensions@npm:^2.0.0":
version: 2.3.0
resolution: "binary-extensions@npm:2.3.0"
@@ -4875,6 +5405,13 @@ __metadata:
languageName: node
linkType: hard
+"chai@npm:^6.2.2":
+ version: 6.2.2
+ resolution: "chai@npm:6.2.2"
+ checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53
+ languageName: node
+ linkType: hard
+
"chalk@npm:^4.0.0":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@@ -5177,6 +5714,23 @@ __metadata:
languageName: node
linkType: hard
+"css-tree@npm:^3.0.0, css-tree@npm:^3.2.1":
+ version: 3.2.1
+ resolution: "css-tree@npm:3.2.1"
+ dependencies:
+ mdn-data: "npm:2.27.1"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/1f65e9ccaa56112a4706d6f003dd43d777f0dbcf848e66fd320f823192533581f8dd58daa906cb80622658332d50284d6be13b87a6ab4556cbbfe9ef535bbf7e
+ languageName: node
+ linkType: hard
+
+"css.escape@npm:^1.5.1":
+ version: 1.5.1
+ resolution: "css.escape@npm:1.5.1"
+ checksum: 10c0/5e09035e5bf6c2c422b40c6df2eb1529657a17df37fda5d0433d722609527ab98090baf25b13970ca754079a0f3161dd3dfc0e743563ded8cfa0749d861c1525
+ languageName: node
+ linkType: hard
+
"cssesc@npm:^3.0.0":
version: 3.0.0
resolution: "cssesc@npm:3.0.0"
@@ -5207,6 +5761,16 @@ __metadata:
languageName: node
linkType: hard
+"data-urls@npm:^7.0.0":
+ version: 7.0.0
+ resolution: "data-urls@npm:7.0.0"
+ dependencies:
+ whatwg-mimetype: "npm:^5.0.0"
+ whatwg-url: "npm:^16.0.0"
+ checksum: 10c0/08d88ef50d8966a070ffdaa703e1e4b29f01bb2da364dfbc1612b1c2a4caa8045802c9532d81347b21781100132addb36a585071c8323b12cce97973961dee9f
+ languageName: node
+ linkType: hard
+
"data-view-buffer@npm:^1.0.2":
version: 1.0.2
resolution: "data-view-buffer@npm:1.0.2"
@@ -5277,6 +5841,13 @@ __metadata:
languageName: node
linkType: hard
+"decimal.js@npm:^10.6.0":
+ version: 10.6.0
+ resolution: "decimal.js@npm:10.6.0"
+ checksum: 10c0/07d69fbcc54167a340d2d97de95f546f9ff1f69d2b45a02fd7a5292412df3cd9eb7e23065e532a318f5474a2e1bccf8392fdf0443ef467f97f3bf8cb0477e5aa
+ languageName: node
+ linkType: hard
+
"dedent@npm:^1.6.0":
version: 1.7.1
resolution: "dedent@npm:1.7.1"
@@ -5379,6 +5950,13 @@ __metadata:
languageName: node
linkType: hard
+"dequal@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "dequal@npm:2.0.3"
+ checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888
+ languageName: node
+ linkType: hard
+
"destr@npm:^2.0.3":
version: 2.0.5
resolution: "destr@npm:2.0.5"
@@ -5439,6 +6017,20 @@ __metadata:
languageName: node
linkType: hard
+"dom-accessibility-api@npm:^0.5.9":
+ version: 0.5.16
+ resolution: "dom-accessibility-api@npm:0.5.16"
+ checksum: 10c0/b2c2eda4fae568977cdac27a9f0c001edf4f95a6a6191dfa611e3721db2478d1badc01db5bb4fa8a848aeee13e442a6c2a4386d65ec65a1436f24715a2f8d053
+ languageName: node
+ linkType: hard
+
+"dom-accessibility-api@npm:^0.6.3":
+ version: 0.6.3
+ resolution: "dom-accessibility-api@npm:0.6.3"
+ checksum: 10c0/10bee5aa514b2a9a37c87cd81268db607a2e933a050074abc2f6fa3da9080ebed206a320cbc123567f2c3087d22292853bdfdceaffdd4334ffe2af9510b29360
+ languageName: node
+ linkType: hard
+
"dotenv@npm:^16.0.0, dotenv@npm:^16.6.1":
version: 16.6.1
resolution: "dotenv@npm:16.6.1"
@@ -5563,6 +6155,13 @@ __metadata:
languageName: node
linkType: hard
+"entities@npm:^6.0.0":
+ version: 6.0.1
+ resolution: "entities@npm:6.0.1"
+ checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414
+ languageName: node
+ linkType: hard
+
"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
@@ -5686,6 +6285,13 @@ __metadata:
languageName: node
linkType: hard
+"es-module-lexer@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "es-module-lexer@npm:2.0.0"
+ checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817
+ languageName: node
+ linkType: hard
+
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
@@ -6366,6 +6972,15 @@ __metadata:
languageName: node
linkType: hard
+"estree-walker@npm:^3.0.3":
+ version: 3.0.3
+ resolution: "estree-walker@npm:3.0.3"
+ dependencies:
+ "@types/estree": "npm:^1.0.0"
+ checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d
+ languageName: node
+ linkType: hard
+
"esutils@npm:^2.0.2, esutils@npm:^2.0.3":
version: 2.0.3
resolution: "esutils@npm:2.0.3"
@@ -6447,6 +7062,13 @@ __metadata:
languageName: node
linkType: hard
+"expect-type@npm:^1.3.0":
+ version: 1.3.0
+ resolution: "expect-type@npm:1.3.0"
+ checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd
+ languageName: node
+ linkType: hard
+
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
@@ -6710,6 +7332,13 @@ __metadata:
languageName: node
linkType: hard
+"fflate@npm:^0.8.2":
+ version: 0.8.2
+ resolution: "fflate@npm:0.8.2"
+ checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293
+ languageName: node
+ linkType: hard
+
"figures@npm:^6.1.0":
version: 6.1.0
resolution: "figures@npm:6.1.0"
@@ -6802,6 +7431,13 @@ __metadata:
languageName: node
linkType: hard
+"flatted@npm:3.4.0":
+ version: 3.4.0
+ resolution: "flatted@npm:3.4.0"
+ checksum: 10c0/033b0d28dc7c11c20cdddfef160647d37ee6f49cac265e6315d7c172a8a518a971316938d49c72cce3e20bddd40f1bae1455a5cba29f9741fcfb0af4d3491fa4
+ languageName: node
+ linkType: hard
+
"flatted@npm:^3.2.9":
version: 3.3.3
resolution: "flatted@npm:3.3.3"
@@ -6847,14 +7483,22 @@ __metadata:
dependencies:
"@hookform/resolvers": "npm:^5.2.2"
"@tailwindcss/postcss": "npm:^4"
+ "@testing-library/dom": "npm:^10.4.1"
+ "@testing-library/jest-dom": "npm:^6.9.1"
+ "@testing-library/react": "npm:^16.3.2"
+ "@testing-library/user-event": "npm:^14.6.1"
"@types/node": "npm:^20"
"@types/react": "npm:^19"
"@types/react-dom": "npm:^19"
+ "@vitejs/plugin-react": "npm:^6.0.1"
+ "@vitest/coverage-v8": "npm:^4.1.0"
+ "@vitest/ui": "npm:^4.1.0"
class-variance-authority: "npm:^0.7.1"
clsx: "npm:^2.1.1"
date-fns: "npm:^4.1.0"
eslint: "npm:^9"
eslint-config-next: "npm:16.1.6"
+ jsdom: "npm:^29.0.1"
lucide-react: "npm:^0.575.0"
next: "npm:16.1.6"
radix-ui: "npm:^1.4.3"
@@ -6866,6 +7510,8 @@ __metadata:
tailwindcss: "npm:^4"
tw-animate-css: "npm:^1.4.0"
typescript: "npm:^5"
+ vite: "npm:^8.0.1"
+ vitest: "npm:^4.1.0"
zod: "npm:^4.3.6"
languageName: unknown
linkType: soft
@@ -7308,6 +7954,22 @@ __metadata:
languageName: node
linkType: hard
+"html-encoding-sniffer@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "html-encoding-sniffer@npm:6.0.0"
+ dependencies:
+ "@exodus/bytes": "npm:^1.6.0"
+ checksum: 10c0/66dc3f6f5539cc3beb814fcbfae7eacf4ec38cf824d6e1425b72039b51a40f4456bd8541ba66f4f4fe09cdf885ab5cd5bae6ec6339d6895a930b2fdb83c53025
+ languageName: node
+ linkType: hard
+
+"html-escaper@npm:^2.0.0":
+ version: 2.0.2
+ resolution: "html-escaper@npm:2.0.2"
+ checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0
+ languageName: node
+ linkType: hard
+
"http-cache-semantics@npm:^4.1.1":
version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0"
@@ -7418,6 +8080,13 @@ __metadata:
languageName: node
linkType: hard
+"indent-string@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "indent-string@npm:4.0.0"
+ checksum: 10c0/1e1904ddb0cb3d6cce7cd09e27a90184908b7a5d5c21b92e232c93579d314f0b83c246ffb035493d0504b1e9147ba2c9b21df0030f48673fba0496ecd698161f
+ languageName: node
+ linkType: hard
+
"inflight@npm:^1.0.4":
version: 1.0.6
resolution: "inflight@npm:1.0.6"
@@ -7710,6 +8379,13 @@ __metadata:
languageName: node
linkType: hard
+"is-potential-custom-element-name@npm:^1.0.1":
+ version: 1.0.1
+ resolution: "is-potential-custom-element-name@npm:1.0.1"
+ checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9
+ languageName: node
+ linkType: hard
+
"is-promise@npm:^4.0.0":
version: 4.0.0
resolution: "is-promise@npm:4.0.0"
@@ -7866,6 +8542,34 @@ __metadata:
languageName: node
linkType: hard
+"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2":
+ version: 3.2.2
+ resolution: "istanbul-lib-coverage@npm:3.2.2"
+ checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b
+ languageName: node
+ linkType: hard
+
+"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1":
+ version: 3.0.1
+ resolution: "istanbul-lib-report@npm:3.0.1"
+ dependencies:
+ istanbul-lib-coverage: "npm:^3.0.0"
+ make-dir: "npm:^4.0.0"
+ supports-color: "npm:^7.1.0"
+ checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7
+ languageName: node
+ linkType: hard
+
+"istanbul-reports@npm:^3.2.0":
+ version: 3.2.0
+ resolution: "istanbul-reports@npm:3.2.0"
+ dependencies:
+ html-escaper: "npm:^2.0.0"
+ istanbul-lib-report: "npm:^3.0.0"
+ checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc
+ languageName: node
+ linkType: hard
+
"iterator.prototype@npm:^1.1.5":
version: 1.1.5
resolution: "iterator.prototype@npm:1.1.5"
@@ -7903,6 +8607,13 @@ __metadata:
languageName: node
linkType: hard
+"js-tokens@npm:^10.0.0":
+ version: 10.0.0
+ resolution: "js-tokens@npm:10.0.0"
+ checksum: 10c0/a93498747812ba3e0c8626f95f75ab29319f2a13613a0de9e610700405760931624433a0de59eb7c27ff8836e526768fb20783861b86ef89be96676f2c996b64
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -7921,6 +8632,40 @@ __metadata:
languageName: node
linkType: hard
+"jsdom@npm:^29.0.1":
+ version: 29.0.1
+ resolution: "jsdom@npm:29.0.1"
+ dependencies:
+ "@asamuzakjp/css-color": "npm:^5.0.1"
+ "@asamuzakjp/dom-selector": "npm:^7.0.3"
+ "@bramus/specificity": "npm:^2.4.2"
+ "@csstools/css-syntax-patches-for-csstree": "npm:^1.1.1"
+ "@exodus/bytes": "npm:^1.15.0"
+ css-tree: "npm:^3.2.1"
+ data-urls: "npm:^7.0.0"
+ decimal.js: "npm:^10.6.0"
+ html-encoding-sniffer: "npm:^6.0.0"
+ is-potential-custom-element-name: "npm:^1.0.1"
+ lru-cache: "npm:^11.2.7"
+ parse5: "npm:^8.0.0"
+ saxes: "npm:^6.0.0"
+ symbol-tree: "npm:^3.2.4"
+ tough-cookie: "npm:^6.0.1"
+ undici: "npm:^7.24.5"
+ w3c-xmlserializer: "npm:^5.0.0"
+ webidl-conversions: "npm:^8.0.1"
+ whatwg-mimetype: "npm:^5.0.0"
+ whatwg-url: "npm:^16.0.1"
+ xml-name-validator: "npm:^5.0.0"
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+ checksum: 10c0/f8eeadc9bb45fb5736501f855b5f8247c9eadcd7f52ef2e11677c3a2197284051b4623004889543eb9613ecdfb47ddb5405b822d9623b0524edd901288cc361d
+ languageName: node
+ linkType: hard
+
"jsesc@npm:^3.0.2":
version: 3.1.0
resolution: "jsesc@npm:3.1.0"
@@ -8093,6 +8838,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-android-arm64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-android-arm64@npm:1.32.0"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
"lightningcss-darwin-arm64@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-darwin-arm64@npm:1.31.1"
@@ -8100,6 +8852,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-darwin-arm64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-darwin-arm64@npm:1.32.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
"lightningcss-darwin-x64@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-darwin-x64@npm:1.31.1"
@@ -8107,6 +8866,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-darwin-x64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-darwin-x64@npm:1.32.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
"lightningcss-freebsd-x64@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-freebsd-x64@npm:1.31.1"
@@ -8114,6 +8880,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-freebsd-x64@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-freebsd-x64@npm:1.32.0"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-arm-gnueabihf@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-linux-arm-gnueabihf@npm:1.31.1"
@@ -8121,6 +8894,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-arm-gnueabihf@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-arm64-gnu@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-linux-arm64-gnu@npm:1.31.1"
@@ -8128,6 +8908,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-arm64-gnu@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-arm64-musl@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-linux-arm64-musl@npm:1.31.1"
@@ -8135,6 +8922,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-arm64-musl@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-arm64-musl@npm:1.32.0"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-x64-gnu@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-linux-x64-gnu@npm:1.31.1"
@@ -8142,6 +8936,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-x64-gnu@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-x64-gnu@npm:1.32.0"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
"lightningcss-linux-x64-musl@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-linux-x64-musl@npm:1.31.1"
@@ -8149,6 +8950,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-linux-x64-musl@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-linux-x64-musl@npm:1.32.0"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
"lightningcss-win32-arm64-msvc@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-win32-arm64-msvc@npm:1.31.1"
@@ -8156,6 +8964,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-win32-arm64-msvc@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
"lightningcss-win32-x64-msvc@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss-win32-x64-msvc@npm:1.31.1"
@@ -8163,6 +8978,13 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-win32-x64-msvc@npm:1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss-win32-x64-msvc@npm:1.32.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
"lightningcss@npm:1.31.1":
version: 1.31.1
resolution: "lightningcss@npm:1.31.1"
@@ -8206,6 +9028,49 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss@npm:^1.32.0":
+ version: 1.32.0
+ resolution: "lightningcss@npm:1.32.0"
+ dependencies:
+ detect-libc: "npm:^2.0.3"
+ lightningcss-android-arm64: "npm:1.32.0"
+ lightningcss-darwin-arm64: "npm:1.32.0"
+ lightningcss-darwin-x64: "npm:1.32.0"
+ lightningcss-freebsd-x64: "npm:1.32.0"
+ lightningcss-linux-arm-gnueabihf: "npm:1.32.0"
+ lightningcss-linux-arm64-gnu: "npm:1.32.0"
+ lightningcss-linux-arm64-musl: "npm:1.32.0"
+ lightningcss-linux-x64-gnu: "npm:1.32.0"
+ lightningcss-linux-x64-musl: "npm:1.32.0"
+ lightningcss-win32-arm64-msvc: "npm:1.32.0"
+ lightningcss-win32-x64-msvc: "npm:1.32.0"
+ dependenciesMeta:
+ lightningcss-android-arm64:
+ optional: true
+ lightningcss-darwin-arm64:
+ optional: true
+ lightningcss-darwin-x64:
+ optional: true
+ lightningcss-freebsd-x64:
+ optional: true
+ lightningcss-linux-arm-gnueabihf:
+ optional: true
+ lightningcss-linux-arm64-gnu:
+ optional: true
+ lightningcss-linux-arm64-musl:
+ optional: true
+ lightningcss-linux-x64-gnu:
+ optional: true
+ lightningcss-linux-x64-musl:
+ optional: true
+ lightningcss-win32-arm64-msvc:
+ optional: true
+ lightningcss-win32-x64-msvc:
+ optional: true
+ checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03
+ languageName: node
+ linkType: hard
+
"lines-and-columns@npm:^1.1.6":
version: 1.2.4
resolution: "lines-and-columns@npm:1.2.4"
@@ -8280,6 +9145,13 @@ __metadata:
languageName: node
linkType: hard
+"lru-cache@npm:^11.2.6, lru-cache@npm:^11.2.7":
+ version: 11.2.7
+ resolution: "lru-cache@npm:11.2.7"
+ checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7
+ languageName: node
+ linkType: hard
+
"lru-cache@npm:^5.1.1":
version: 5.1.1
resolution: "lru-cache@npm:5.1.1"
@@ -8298,6 +9170,15 @@ __metadata:
languageName: node
linkType: hard
+"lz-string@npm:^1.5.0":
+ version: 1.5.0
+ resolution: "lz-string@npm:1.5.0"
+ bin:
+ lz-string: bin/bin.js
+ checksum: 10c0/36128e4de34791838abe979b19927c26e67201ca5acf00880377af7d765b38d1c60847e01c5ec61b1a260c48029084ab3893a3925fd6e48a04011364b089991b
+ languageName: node
+ linkType: hard
+
"magic-string@npm:^0.30.0, magic-string@npm:^0.30.1, magic-string@npm:^0.30.21":
version: 0.30.21
resolution: "magic-string@npm:0.30.21"
@@ -8307,6 +9188,26 @@ __metadata:
languageName: node
linkType: hard
+"magicast@npm:^0.5.2":
+ version: 0.5.2
+ resolution: "magicast@npm:0.5.2"
+ dependencies:
+ "@babel/parser": "npm:^7.29.0"
+ "@babel/types": "npm:^7.29.0"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/924af677643c5a0a7d6cdb3247c0eb96fa7611b2ba6a5e720d35d81c503d3d9f5948eb5227f80f90f82ea3e7d38cffd10bb988f3fc09020db428e14f26e960d7
+ languageName: node
+ linkType: hard
+
+"make-dir@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "make-dir@npm:4.0.0"
+ dependencies:
+ semver: "npm:^7.5.3"
+ checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68
+ languageName: node
+ linkType: hard
+
"make-error@npm:^1.1.1":
version: 1.3.6
resolution: "make-error@npm:1.3.6"
@@ -8349,6 +9250,13 @@ __metadata:
languageName: node
linkType: hard
+"mdn-data@npm:2.27.1":
+ version: 2.27.1
+ resolution: "mdn-data@npm:2.27.1"
+ checksum: 10c0/eb8abf5d22e4d1e090346f5e81b67d23cef14c83940e445da5c44541ad874dc8fb9f6ca236e8258c3a489d9fb5884188a4d7d58773adb9089ac2c0b966796393
+ languageName: node
+ linkType: hard
+
"media-typer@npm:^1.1.0":
version: 1.1.0
resolution: "media-typer@npm:1.1.0"
@@ -8417,6 +9325,13 @@ __metadata:
languageName: node
linkType: hard
+"min-indent@npm:^1.0.0":
+ version: 1.0.1
+ resolution: "min-indent@npm:1.0.1"
+ checksum: 10c0/7e207bd5c20401b292de291f02913230cb1163abca162044f7db1d951fa245b174dc00869d40dd9a9f32a885ad6a5f3e767ee104cf278f399cb4e92d3f582d5c
+ languageName: node
+ linkType: hard
+
"minimatch@npm:^10.0.1":
version: 10.2.2
resolution: "minimatch@npm:10.2.2"
@@ -8566,6 +9481,13 @@ __metadata:
languageName: node
linkType: hard
+"mrmime@npm:^2.0.0":
+ version: 2.0.1
+ resolution: "mrmime@npm:2.0.1"
+ checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761
+ languageName: node
+ linkType: hard
+
"ms@npm:^2.1.1, ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"
@@ -8915,6 +9837,13 @@ __metadata:
languageName: node
linkType: hard
+"obug@npm:^2.1.1":
+ version: 2.1.1
+ resolution: "obug@npm:2.1.1"
+ checksum: 10c0/59dccd7de72a047e08f8649e94c1015ec72f94eefb6ddb57fb4812c4b425a813bc7e7cd30c9aca20db3c59abc3c85cc7a62bb656a968741d770f4e8e02bc2e78
+ languageName: node
+ linkType: hard
+
"ohash@npm:^2.0.11":
version: 2.0.11
resolution: "ohash@npm:2.0.11"
@@ -9097,6 +10026,15 @@ __metadata:
languageName: node
linkType: hard
+"parse5@npm:^8.0.0":
+ version: 8.0.0
+ resolution: "parse5@npm:8.0.0"
+ dependencies:
+ entities: "npm:^6.0.0"
+ checksum: 10c0/8279892dcd77b2f2229707f60eb039e303adf0288812b2a8fd5acf506a4d432da833c6c5d07a6554bef722c2367a81ef4a1f7e9336564379a7dba3e798bf16b3
+ languageName: node
+ linkType: hard
+
"parseurl@npm:^1.3.3":
version: 1.3.3
resolution: "parseurl@npm:1.3.3"
@@ -9198,7 +10136,7 @@ __metadata:
languageName: node
linkType: hard
-"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1":
+"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.1":
version: 1.1.1
resolution: "picocolors@npm:1.1.1"
checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58
@@ -9386,6 +10324,17 @@ __metadata:
languageName: node
linkType: hard
+"postcss@npm:^8.5.8":
+ version: 8.5.8
+ resolution: "postcss@npm:8.5.8"
+ dependencies:
+ nanoid: "npm:^3.3.11"
+ picocolors: "npm:^1.1.1"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/dd918f7127ee7c60a0295bae2e72b3787892296e1d1c3c564d7a2a00c68d8df83cadc3178491259daa19ccc54804fb71ed8c937c6787e08d8bd4bedf8d17044c
+ languageName: node
+ linkType: hard
+
"powershell-utils@npm:^0.1.0":
version: 0.1.0
resolution: "powershell-utils@npm:0.1.0"
@@ -9427,7 +10376,7 @@ __metadata:
languageName: node
linkType: hard
-"pretty-format@npm:^27.5.1":
+"pretty-format@npm:^27.0.2, pretty-format@npm:^27.5.1":
version: 27.5.1
resolution: "pretty-format@npm:27.5.1"
dependencies:
@@ -9544,7 +10493,7 @@ __metadata:
languageName: node
linkType: hard
-"punycode@npm:^2.1.0":
+"punycode@npm:^2.1.0, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9
@@ -9875,6 +10824,16 @@ __metadata:
languageName: node
linkType: hard
+"redent@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "redent@npm:3.0.0"
+ dependencies:
+ indent-string: "npm:^4.0.0"
+ strip-indent: "npm:^3.0.0"
+ checksum: 10c0/d64a6b5c0b50eb3ddce3ab770f866658a2b9998c678f797919ceb1b586bab9259b311407280bd80b804e2a7c7539b19238ae6a2a20c843f1a7fcff21d48c2eae
+ languageName: node
+ linkType: hard
+
"reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9":
version: 1.0.10
resolution: "reflect.getprototypeof@npm:1.0.10"
@@ -10058,6 +11017,64 @@ __metadata:
languageName: node
linkType: hard
+"rolldown@npm:1.0.0-rc.10":
+ version: 1.0.0-rc.10
+ resolution: "rolldown@npm:1.0.0-rc.10"
+ dependencies:
+ "@oxc-project/types": "npm:=0.120.0"
+ "@rolldown/binding-android-arm64": "npm:1.0.0-rc.10"
+ "@rolldown/binding-darwin-arm64": "npm:1.0.0-rc.10"
+ "@rolldown/binding-darwin-x64": "npm:1.0.0-rc.10"
+ "@rolldown/binding-freebsd-x64": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-arm-gnueabihf": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-arm64-gnu": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-arm64-musl": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-ppc64-gnu": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-s390x-gnu": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-x64-gnu": "npm:1.0.0-rc.10"
+ "@rolldown/binding-linux-x64-musl": "npm:1.0.0-rc.10"
+ "@rolldown/binding-openharmony-arm64": "npm:1.0.0-rc.10"
+ "@rolldown/binding-wasm32-wasi": "npm:1.0.0-rc.10"
+ "@rolldown/binding-win32-arm64-msvc": "npm:1.0.0-rc.10"
+ "@rolldown/binding-win32-x64-msvc": "npm:1.0.0-rc.10"
+ "@rolldown/pluginutils": "npm:1.0.0-rc.10"
+ dependenciesMeta:
+ "@rolldown/binding-android-arm64":
+ optional: true
+ "@rolldown/binding-darwin-arm64":
+ optional: true
+ "@rolldown/binding-darwin-x64":
+ optional: true
+ "@rolldown/binding-freebsd-x64":
+ optional: true
+ "@rolldown/binding-linux-arm-gnueabihf":
+ optional: true
+ "@rolldown/binding-linux-arm64-gnu":
+ optional: true
+ "@rolldown/binding-linux-arm64-musl":
+ optional: true
+ "@rolldown/binding-linux-ppc64-gnu":
+ optional: true
+ "@rolldown/binding-linux-s390x-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-musl":
+ optional: true
+ "@rolldown/binding-openharmony-arm64":
+ optional: true
+ "@rolldown/binding-wasm32-wasi":
+ optional: true
+ "@rolldown/binding-win32-arm64-msvc":
+ optional: true
+ "@rolldown/binding-win32-x64-msvc":
+ optional: true
+ bin:
+ rolldown: bin/cli.mjs
+ checksum: 10c0/3d7970ce31bb4b267c3240a1c03f275483f8523484b1218b75a4cc3ddffa188e58f73b9b3e0bec850544db3839754015959fdea87278c9ccf93ab76b4fb8672a
+ languageName: node
+ linkType: hard
+
"rollup@npm:^3.27.1":
version: 3.29.5
resolution: "rollup@npm:3.29.5"
@@ -10255,6 +11272,15 @@ __metadata:
languageName: node
linkType: hard
+"saxes@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "saxes@npm:6.0.0"
+ dependencies:
+ xmlchars: "npm:^2.2.0"
+ checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74
+ languageName: node
+ linkType: hard
+
"scheduler@npm:^0.27.0":
version: 0.27.0
resolution: "scheduler@npm:0.27.0"
@@ -10285,7 +11311,7 @@ __metadata:
languageName: node
linkType: hard
-"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.5.4, semver@npm:^7.7.1, semver@npm:^7.7.3":
+"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.7.1, semver@npm:^7.7.3":
version: 7.7.4
resolution: "semver@npm:7.7.4"
bin:
@@ -10590,6 +11616,17 @@ __metadata:
languageName: node
linkType: hard
+"sirv@npm:^3.0.2":
+ version: 3.0.2
+ resolution: "sirv@npm:3.0.2"
+ dependencies:
+ "@polka/url": "npm:^1.0.0-next.24"
+ mrmime: "npm:^2.0.0"
+ totalist: "npm:^3.0.0"
+ checksum: 10c0/5930e4397afdb14fbae13751c3be983af4bda5c9aadec832607dc2af15a7162f7d518c71b30e83ae3644b9a24cea041543cc969e5fe2b80af6ce8ea3174b2d04
+ languageName: node
+ linkType: hard
+
"sisteransi@npm:^1.0.5":
version: 1.0.5
resolution: "sisteransi@npm:1.0.5"
@@ -10711,6 +11748,13 @@ __metadata:
languageName: node
linkType: hard
+"std-env@npm:^4.0.0-rc.1":
+ version: 4.0.0
+ resolution: "std-env@npm:4.0.0"
+ checksum: 10c0/63b1716eae27947adde49e21b7225a0f75fb2c3d410273ae9de8333c07c7d5fc7a0628ae4c8af6b4b49b4274ed46c2bf118ed69b64f1261c9d8213d76ed1c16c
+ languageName: node
+ linkType: hard
+
"stdin-discarder@npm:^0.2.2":
version: 0.2.2
resolution: "stdin-discarder@npm:0.2.2"
@@ -10896,6 +11940,15 @@ __metadata:
languageName: node
linkType: hard
+"strip-indent@npm:^3.0.0":
+ version: 3.0.0
+ resolution: "strip-indent@npm:3.0.0"
+ dependencies:
+ min-indent: "npm:^1.0.0"
+ checksum: 10c0/ae0deaf41c8d1001c5d4fbe16cb553865c1863da4fae036683b474fa926af9fc121e155cb3fc57a68262b2ae7d5b8420aa752c97a6428c315d00efe2a3875679
+ languageName: node
+ linkType: hard
+
"strip-json-comments@npm:^2.0.0":
version: 2.0.1
resolution: "strip-json-comments@npm:2.0.1"
@@ -10958,6 +12011,13 @@ __metadata:
languageName: node
linkType: hard
+"symbol-tree@npm:^3.2.4":
+ version: 3.2.4
+ resolution: "symbol-tree@npm:3.2.4"
+ checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509
+ languageName: node
+ linkType: hard
+
"synckit@npm:^0.11.12":
version: 0.11.12
resolution: "synckit@npm:0.11.12"
@@ -11047,7 +12107,7 @@ __metadata:
languageName: node
linkType: hard
-"tinybench@npm:^2.5.0":
+"tinybench@npm:^2.5.0, tinybench@npm:^2.9.0":
version: 2.9.0
resolution: "tinybench@npm:2.9.0"
checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c
@@ -11085,6 +12145,13 @@ __metadata:
languageName: node
linkType: hard
+"tinyrainbow@npm:^3.0.3":
+ version: 3.1.0
+ resolution: "tinyrainbow@npm:3.1.0"
+ checksum: 10c0/f11cf387a26c5c9255bec141a90ac511b26172981b10c3e50053bc6700ea7d2336edcc4a3a21dbb8412fe7c013477d2ba4d7e4877800f3f8107be5105aad6511
+ languageName: node
+ linkType: hard
+
"tinyspy@npm:^2.1.0, tinyspy@npm:^2.1.1":
version: 2.2.1
resolution: "tinyspy@npm:2.2.1"
@@ -11133,6 +12200,13 @@ __metadata:
languageName: node
linkType: hard
+"totalist@npm:^3.0.0":
+ version: 3.0.1
+ resolution: "totalist@npm:3.0.1"
+ checksum: 10c0/4bb1fadb69c3edbef91c73ebef9d25b33bbf69afe1e37ce544d5f7d13854cda15e47132f3e0dc4cafe300ddb8578c77c50a65004d8b6e97e77934a69aa924863
+ languageName: node
+ linkType: hard
+
"tough-cookie@npm:^6.0.0":
version: 6.0.0
resolution: "tough-cookie@npm:6.0.0"
@@ -11142,6 +12216,24 @@ __metadata:
languageName: node
linkType: hard
+"tough-cookie@npm:^6.0.1":
+ version: 6.0.1
+ resolution: "tough-cookie@npm:6.0.1"
+ dependencies:
+ tldts: "npm:^7.0.5"
+ checksum: 10c0/ec70bd6b1215efe4ed31a158f0be3e4c9088fcbd8620edc23a5860d4f3d85c757b77e274baaa700f7b25e409f4181552ed189603c2b2e1a9f88104da3a61a37d
+ languageName: node
+ linkType: hard
+
+"tr46@npm:^6.0.0":
+ version: 6.0.0
+ resolution: "tr46@npm:6.0.0"
+ dependencies:
+ punycode: "npm:^2.3.1"
+ checksum: 10c0/83130df2f649228aa91c17754b66248030a3af34911d713b5ea417066fa338aa4bc8668d06bd98aa21a2210f43fc0a3db8b9099e7747fb5830e40e39a6a1058e
+ languageName: node
+ linkType: hard
+
"tree-kill@npm:^1.2.2":
version: 1.2.2
resolution: "tree-kill@npm:1.2.2"
@@ -11464,6 +12556,13 @@ __metadata:
languageName: node
linkType: hard
+"undici@npm:^7.24.5":
+ version: 7.24.5
+ resolution: "undici@npm:7.24.5"
+ checksum: 10c0/2a836f1f6ab078fde3eeb4cc8fd5b34eeaf52cfbdf16a9bab61b7223f43f7847bcd2125d1da7c4e3f5996c528bf9f7940015d39909bab80cfbd71b855470cf21
+ languageName: node
+ linkType: hard
+
"unicorn-magic@npm:^0.3.0":
version: 0.3.0
resolution: "unicorn-magic@npm:0.3.0"
@@ -11783,6 +12882,63 @@ __metadata:
languageName: node
linkType: hard
+"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0-0, vite@npm:^8.0.1":
+ version: 8.0.1
+ resolution: "vite@npm:8.0.1"
+ dependencies:
+ fsevents: "npm:~2.3.3"
+ lightningcss: "npm:^1.32.0"
+ picomatch: "npm:^4.0.3"
+ postcss: "npm:^8.5.8"
+ rolldown: "npm:1.0.0-rc.10"
+ tinyglobby: "npm:^0.2.15"
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ "@vitejs/devtools": ^0.1.0
+ esbuild: ^0.27.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ "@vitejs/devtools":
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ bin:
+ vite: bin/vite.js
+ checksum: 10c0/f1379726cfd50f3f12d172cf6f61b7b067521bd92955176d0bc6e6e9dd538fe76c87e7f7102d5815e4f83f6795e8ba95502fd442507dc8574ba13bcb7230b2c3
+ languageName: node
+ linkType: hard
+
"vitest@npm:^0.31.0":
version: 0.31.4
resolution: "vitest@npm:0.31.4"
@@ -11904,6 +13060,77 @@ __metadata:
languageName: node
linkType: hard
+"vitest@npm:^4.1.0":
+ version: 4.1.0
+ resolution: "vitest@npm:4.1.0"
+ dependencies:
+ "@vitest/expect": "npm:4.1.0"
+ "@vitest/mocker": "npm:4.1.0"
+ "@vitest/pretty-format": "npm:4.1.0"
+ "@vitest/runner": "npm:4.1.0"
+ "@vitest/snapshot": "npm:4.1.0"
+ "@vitest/spy": "npm:4.1.0"
+ "@vitest/utils": "npm:4.1.0"
+ es-module-lexer: "npm:^2.0.0"
+ expect-type: "npm:^1.3.0"
+ magic-string: "npm:^0.30.21"
+ obug: "npm:^2.1.1"
+ pathe: "npm:^2.0.3"
+ picomatch: "npm:^4.0.3"
+ std-env: "npm:^4.0.0-rc.1"
+ tinybench: "npm:^2.9.0"
+ tinyexec: "npm:^1.0.2"
+ tinyglobby: "npm:^0.2.15"
+ tinyrainbow: "npm:^3.0.3"
+ vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0-0"
+ why-is-node-running: "npm:^2.3.0"
+ peerDependencies:
+ "@edge-runtime/vm": "*"
+ "@opentelemetry/api": ^1.9.0
+ "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
+ "@vitest/browser-playwright": 4.1.0
+ "@vitest/browser-preview": 4.1.0
+ "@vitest/browser-webdriverio": 4.1.0
+ "@vitest/ui": 4.1.0
+ happy-dom: "*"
+ jsdom: "*"
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ peerDependenciesMeta:
+ "@edge-runtime/vm":
+ optional: true
+ "@opentelemetry/api":
+ optional: true
+ "@types/node":
+ optional: true
+ "@vitest/browser-playwright":
+ optional: true
+ "@vitest/browser-preview":
+ optional: true
+ "@vitest/browser-webdriverio":
+ optional: true
+ "@vitest/ui":
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ vite:
+ optional: false
+ bin:
+ vitest: vitest.mjs
+ checksum: 10c0/48048e4391e4e8190aa12b1c868bef4ad8d346214631b4506e0dc1f3241ecb8bcb24f296c38a7d98eae712a042375ae209da4b35165db38f9a9bc79a3a9e2a04
+ languageName: node
+ linkType: hard
+
+"w3c-xmlserializer@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "w3c-xmlserializer@npm:5.0.0"
+ dependencies:
+ xml-name-validator: "npm:^5.0.0"
+ checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b
+ languageName: node
+ linkType: hard
+
"web-streams-polyfill@npm:^3.0.3":
version: 3.3.3
resolution: "web-streams-polyfill@npm:3.3.3"
@@ -11911,6 +13138,13 @@ __metadata:
languageName: node
linkType: hard
+"webidl-conversions@npm:^8.0.1":
+ version: 8.0.1
+ resolution: "webidl-conversions@npm:8.0.1"
+ checksum: 10c0/3f6f327ca5fa0c065ed8ed0ef3b72f33623376e68f958e9b7bd0df49fdb0b908139ac2338d19fb45bd0e05595bda96cb6d1622222a8b413daa38a17aacc4dd46
+ languageName: node
+ linkType: hard
+
"well-known-symbols@npm:^2.0.0":
version: 2.0.0
resolution: "well-known-symbols@npm:2.0.0"
@@ -11918,6 +13152,24 @@ __metadata:
languageName: node
linkType: hard
+"whatwg-mimetype@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "whatwg-mimetype@npm:5.0.0"
+ checksum: 10c0/eead164fe73a00dd82f817af6fc0bd22e9c273e1d55bf4bc6bdf2da7ad8127fca82ef00ea6a37892f5f5641f8e34128e09508f92126086baba126b9e0d57feb4
+ languageName: node
+ linkType: hard
+
+"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1":
+ version: 16.0.1
+ resolution: "whatwg-url@npm:16.0.1"
+ dependencies:
+ "@exodus/bytes": "npm:^1.11.0"
+ tr46: "npm:^6.0.0"
+ webidl-conversions: "npm:^8.0.1"
+ checksum: 10c0/e75565566abf3a2cdbd9f06c965dbcccee6ec4e9f0d3728ad5e08ceb9944279848bcaa211d35a29cb6d2df1e467dd05cfb59fbddf8a0adcd7d0bce9ffb703fd2
+ languageName: node
+ linkType: hard
+
"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1":
version: 1.1.1
resolution: "which-boxed-primitive@npm:1.1.1"
@@ -12012,7 +13264,7 @@ __metadata:
languageName: node
linkType: hard
-"why-is-node-running@npm:^2.2.2":
+"why-is-node-running@npm:^2.2.2, why-is-node-running@npm:^2.3.0":
version: 2.3.0
resolution: "why-is-node-running@npm:2.3.0"
dependencies:
@@ -12070,6 +13322,20 @@ __metadata:
languageName: node
linkType: hard
+"xml-name-validator@npm:^5.0.0":
+ version: 5.0.0
+ resolution: "xml-name-validator@npm:5.0.0"
+ checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5
+ languageName: node
+ linkType: hard
+
+"xmlchars@npm:^2.2.0":
+ version: 2.2.0
+ resolution: "xmlchars@npm:2.2.0"
+ checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593
+ languageName: node
+ linkType: hard
+
"xtend@npm:^4.0.0":
version: 4.0.2
resolution: "xtend@npm:4.0.2"