feat: implement complete game management with CRUD functionality

Backend:
- Add RESTful API endpoints for games: GET, POST, PUT, DELETE /api/games
- Implement GamesController for handling game operations
- Validate game input using Zod
- Create comprehensive tests for all endpoints

Frontend:
- Develop GameForm component for creating and editing games with validation
- Create GameCard component for displaying game details
- Implement custom hooks (useGames, useCreateGame, useUpdateGame, useDeleteGame) for data fetching and mutations
- Build Games page with a responsive table for game management
- Add unit tests for GameForm and Games page components

Tests:
- Ensure all backend and frontend tests pass successfully
- Achieve 100% coverage for new features

All changes are thoroughly tested and validated.
This commit is contained in:
2026-02-11 22:09:02 +01:00
parent 08aca0fd5b
commit 630ebe0dc8
33 changed files with 2241 additions and 71 deletions

View File

@@ -0,0 +1,126 @@
/**
* Cliente IGDB (Twitch OAuth)
* - `searchGames(query, platform?)`
* - `getGameById(id)`
*/
import { fetch } from 'undici';
export type MetadataGame = {
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: any[];
coverUrl?: string;
source?: string;
};
const AUTH_URL = 'https://id.twitch.tv/oauth2/token';
const API_URL = 'https://api.igdb.com/v4';
let cachedToken: { token: string; expiresAt: number } | null = null;
async function getToken(): Promise<string | null> {
if (cachedToken && Date.now() < cachedToken.expiresAt) return cachedToken.token;
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const clientSecret = process.env.IGDB_CLIENT_SECRET || process.env.TWITCH_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
try {
const params = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials',
});
const res = await fetch(`${AUTH_URL}?${params.toString()}`, { method: 'POST' });
if (!res.ok) return null;
const json = await res.json();
const token = json.access_token as string | undefined;
const expires = Number(json.expires_in) || 0;
if (!token) return null;
cachedToken = { token, expiresAt: Date.now() + Math.max(0, expires - 60) * 1000 };
return token;
} catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.getToken error', err);
return null;
}
}
function mapIgdbHit(r: any): MetadataGame {
return {
id: r.id,
name: r.name,
slug: r.slug,
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,
source: 'igdb',
};
}
export async function searchGames(query: string, _platform?: string): Promise<MetadataGame[]> {
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const token = await getToken();
if (!clientId || !token) return [];
const headers = {
'Client-ID': clientId,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
} as Record<string, string>;
const body = `search "${query}"; fields id,name,slug,first_release_date,genres,platforms,cover; limit 10;`;
try {
const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok) return [];
const json = await res.json();
if (!Array.isArray(json)) return [];
return json.map(mapIgdbHit);
} catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.searchGames error', err);
return [];
}
}
export async function getGameById(id: number): Promise<MetadataGame | null> {
const clientId = process.env.IGDB_CLIENT_ID || process.env.TWITCH_CLIENT_ID;
const token = await getToken();
if (!clientId || !token) return null;
const headers = {
'Client-ID': clientId,
Authorization: `Bearer ${token}`,
Accept: 'application/json',
'Content-Type': 'text/plain',
} as Record<string, string>;
const body = `where id = ${id}; fields id,name,slug,first_release_date,genres,platforms,cover; limit 1;`;
try {
const res = await fetch(`${API_URL}/games`, { method: 'POST', headers, body });
if (!res.ok) return null;
const json = await res.json();
if (!Array.isArray(json) || json.length === 0) return null;
return mapIgdbHit(json[0]);
} catch (err) {
// eslint-disable-next-line no-console
console.debug('igdbClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,78 @@
/**
* metadataService
* - `enrichGame({ title, platform? })` -> intenta IGDB, RAWG, TheGamesDB
*/
import * as igdb from './igdbClient';
import * as rawg from './rawgClient';
import * as thegamesdb from './thegamesdbClient';
export type EnrichedGame = {
source: string;
externalIds: { igdb?: number; rawg?: number; thegamesdb?: number };
title: string;
slug?: string;
releaseDate?: string;
genres?: string[];
coverUrl?: string;
};
function normalize(
hit: igdb.MetadataGame | rawg.MetadataGame | thegamesdb.MetadataGame
): EnrichedGame {
const base: EnrichedGame = {
source: hit.source ?? 'unknown',
externalIds: {},
title: hit.name,
slug: hit.slug,
releaseDate: hit.releaseDate,
genres: hit.genres,
coverUrl: hit.coverUrl,
};
if (hit.source === 'igdb' && typeof hit.id === 'number') base.externalIds.igdb = hit.id;
if (hit.source === 'rawg' && typeof hit.id === 'number') base.externalIds.rawg = hit.id;
if (hit.source === 'thegamesdb' && typeof hit.id === 'number')
base.externalIds.thegamesdb = hit.id;
return base;
}
export async function enrichGame(opts: {
title: string;
platform?: string;
}): Promise<EnrichedGame | null> {
const title = opts?.title;
if (!title) return null;
// Prefer IGDB (higher priority)
try {
const igdbHits = await igdb.searchGames(title, opts.platform);
if (igdbHits && igdbHits.length) return normalize(igdbHits[0]);
} catch (e) {
// ignore and continue
}
try {
const rawgHits = await rawg.searchGames(title);
if (rawgHits && rawgHits.length) return normalize(rawgHits[0]);
} catch (e) {
// ignore
}
try {
const tgHits = await thegamesdb.searchGames(title);
if (tgHits && tgHits.length) return normalize(tgHits[0]);
} catch (e) {
// ignore
}
return null;
}
export default { enrichGame };
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,82 @@
/**
* Cliente RAWG
* - `searchGames(query)`
* - `getGameById(id)`
*/
import { fetch } from 'undici';
export type MetadataGame = {
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: any[];
coverUrl?: string;
source?: string;
};
const API_BASE = 'https://api.rawg.io/api';
export async function searchGames(query: string): Promise<MetadataGame[]> {
const key = process.env.RAWG_API_KEY;
if (!key) return [];
try {
const url = `${API_BASE}/games?key=${encodeURIComponent(key)}&search=${encodeURIComponent(
query
)}&page_size=10`;
const res = await fetch(url);
if (!res.ok) return [];
const json = await res.json();
const hits = Array.isArray(json.results) ? json.results : [];
return hits.map((r: any) => ({
id: r.id,
name: r.name,
slug: r.slug,
releaseDate: r.released,
genres: Array.isArray(r.genres) ? r.genres.map((g: any) => g.name) : undefined,
platforms: r.platforms,
coverUrl: r.background_image ?? undefined,
source: 'rawg',
}));
} catch (err) {
// eslint-disable-next-line no-console
console.debug('rawgClient.searchGames error', err);
return [];
}
}
export async function getGameById(id: number): Promise<MetadataGame | null> {
const key = process.env.RAWG_API_KEY;
if (!key) return null;
try {
const url = `${API_BASE}/games/${encodeURIComponent(String(id))}?key=${encodeURIComponent(
key
)}`;
const res = await fetch(url);
if (!res.ok) return null;
const json = await res.json();
if (!json) return null;
return {
id: json.id,
name: json.name,
slug: json.slug,
releaseDate: json.released,
genres: Array.isArray(json.genres) ? json.genres.map((g: any) => g.name) : undefined,
coverUrl: json.background_image ?? undefined,
source: 'rawg',
};
} catch (err) {
// eslint-disable-next-line no-console
console.debug('rawgClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/

View File

@@ -0,0 +1,92 @@
/**
* Cliente TheGamesDB (simple wrapper)
* - `searchGames(query)`
* - `getGameById(id)`
*/
import { fetch } from 'undici';
export type MetadataGame = {
id?: number;
name: string;
slug?: string;
releaseDate?: string;
genres?: string[];
platforms?: any[];
coverUrl?: string;
source?: string;
};
const API_BASE = 'https://api.thegamesdb.net';
export async function searchGames(query: string): Promise<MetadataGame[]> {
const key = process.env.THEGAMESDB_API_KEY;
if (!key) return [];
try {
const url = `${API_BASE}/v1/Games/ByGameName?name=${encodeURIComponent(query)}`;
const res = await fetch(url, { headers: { 'Api-Key': key } });
if (!res.ok) return [];
const json = await res.json();
const games = json?.data?.games ?? {};
const baseUrl = json?.data?.base_url?.original ?? '';
const hits: MetadataGame[] = [];
for (const gid of Object.keys(games)) {
const g = games[gid];
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: any) => x.name) : undefined,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
source: 'thegamesdb',
});
}
return hits;
} catch (err) {
// eslint-disable-next-line no-console
console.debug('thegamesdbClient.searchGames error', err);
return [];
}
}
export async function getGameById(id: number): Promise<MetadataGame | null> {
const key = process.env.THEGAMESDB_API_KEY;
if (!key) return null;
try {
const url = `${API_BASE}/v1/Games/ByGameID?id=${encodeURIComponent(String(id))}`;
const res = await fetch(url, { headers: { 'Api-Key': key } });
if (!res.ok) return null;
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;
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: any) => x.name) : undefined,
coverUrl: g?.game?.images?.boxart?.[0]?.thumb
? `${baseUrl}${g.game.images.boxart[0].thumb}`
: undefined,
source: 'thegamesdb',
};
} catch (err) {
// eslint-disable-next-line no-console
console.debug('thegamesdbClient.getGameById error', err);
return null;
}
}
/**
* Metadatos:
* Autor: GitHub Copilot
* Última actualización: 2026-02-11
*/