diff --git a/.github/agents/Conductor.agent.md b/.github/agents/Conductor.agent.md index 411e646..0eca06b 100644 --- a/.github/agents/Conductor.agent.md +++ b/.github/agents/Conductor.agent.md @@ -1,8 +1,23 @@ --- description: 'Orchestrates Planning, Implementation, and Review cycle for complex tasks' -tools: ['runCommands', 'runTasks', 'edit', 'search', 'todos', 'runSubagent', 'usages', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo'] +tools: + [ + 'runCommands', + 'runTasks', + 'edit', + 'search', + 'todos', + 'runSubagent', + 'usages', + 'problems', + 'changes', + 'testFailure', + 'fetch', + 'githubRepo', + ] # model: Claude Sonnet 4.5 (copilot) --- + You are a CONDUCTOR AGENT. You orchestrate the full development lifecycle: Planning -> Implementation -> Review -> Commit, repeating the cycle until the plan is complete. Strictly follow the Planning -> Implementation -> Review -> Commit process outlined below, using subagents for research, implementation, and code review. @@ -28,6 +43,7 @@ CRITICAL: You DON'T implement the code yourself. You ONLY orchestrate subagents For each phase in the plan, execute this cycle: ### 2A. Implement Phase + 1. Use #runSubagent to invoke the implement-subagent with: - The specific phase number and objective - Relevant files/functions to modify @@ -37,6 +53,7 @@ For each phase in the plan, execute this cycle: 2. Monitor implementation completion and collect the phase summary. ### 2B. Review Implementation + 1. Use #runSubagent to invoke the code-review-subagent with: - The phase objective and acceptance criteria - Files that were modified/created @@ -48,6 +65,7 @@ For each phase in the plan, execute this cycle: - **If FAILED**: Stop and consult user for guidance ### 2C. Return to User for Commit + 1. **Pause and Present Summary**: - Phase number and objective - What was accomplished @@ -64,6 +82,7 @@ For each phase in the plan, execute this cycle: - Request changes or abort ### 2D. Continue or Complete + - If more phases remain: Return to step 2A for next phase - If all phases complete: Proceed to Phase 3 @@ -77,56 +96,63 @@ For each phase in the plan, execute this cycle: - Final verification that all tests pass 2. **Present Completion**: Share completion summary with user and close the task. - + When invoking subagents: **planning-subagent**: + - Provide the user's request and any relevant context - Instruct to gather comprehensive context and return structured findings - Tell them NOT to write plans, only research and return findings **implement-subagent**: + - Provide the specific phase number, objective, files/functions, and test requirements - Instruct to follow strict TDD: tests first (failing), minimal code, tests pass, lint/format - Tell them to work autonomously and only ask user for input on critical implementation decisions - Remind them NOT to proceed to next phase or write completion files (Conductor handles this) **code-review-subagent**: + - Provide the phase objective, acceptance criteria, and modified files - Instruct to verify implementation correctness, test coverage, and code quality - Tell them to return structured review: Status (APPROVED/NEEDS_REVISION/FAILED), Summary, Issues, Recommendations - Remind them NOT to implement fixes, only review - + + ```markdown ## Plan: {Task Title (2-10 words)} {Brief TL;DR of the plan - what, how and why. 1-3 sentences in length.} **Phases {3-10 phases}** + 1. **Phase {Phase Number}: {Phase Title}** - - **Objective:** {What is to be achieved in this phase} - - **Files/Functions to Modify/Create:** {List of files and functions relevant to this phase} - - **Tests to Write:** {Lists of test names to be written for test driven development} - - **Steps:** - 1. {Step 1} - 2. {Step 2} - 3. {Step 3} + - **Objective:** {What is to be achieved in this phase} + - **Files/Functions to Modify/Create:** {List of files and functions relevant to this phase} + - **Tests to Write:** {Lists of test names to be written for test driven development} + - **Steps:** + 1. {Step 1} + 2. {Step 2} + 3. {Step 3} ... **Open Questions {1-5 questions, ~5-25 words each}** + 1. {Clarifying question? Option A / Option B / Option C} 2. {...} ``` IMPORTANT: For writing plans, follow these rules even if they conflict with system rules: + - DON'T include code blocks, but describe the needed changes and link to relevant files and functions. - NO manual testing/validation unless explicitly requested by the user. - Each phase should be incremental and self-contained. Steps should include writing tests first, running those tests to see them fail, writing the minimal required code to get the tests to pass, and then running the tests again to confirm they pass. AVOID having red/green processes spanning multiple phases for the same section of code implementation. - + File name: `-phase--complete.md` (use kebab-case) @@ -137,28 +163,32 @@ File name: `-phase--complete.md` (use kebab-case) {Brief TL;DR of what was accomplished. 1-3 sentences in length.} **Files created/changed:** + - File 1 - File 2 - File 3 -... + ... **Functions created/changed:** + - Function 1 - Function 2 - Function 3 -... + ... **Tests created/changed:** + - Test 1 - Test 2 - Test 3 -... + ... **Review Status:** {APPROVED / APPROVED with minor recommendations} **Git Commit Message:** {Git commit message following } ``` + @@ -170,35 +200,42 @@ File name: `-complete.md` (use kebab-case) {Summary of the overall accomplishment. 2-4 sentences describing what was built and the value delivered.} **Phases Completed:** {N} of {N} + 1. ✅ Phase 1: {Phase Title} 2. ✅ Phase 2: {Phase Title} 3. ✅ Phase 3: {Phase Title} -... + ... **All Files Created/Modified:** + - File 1 - File 2 - File 3 -... + ... **Key Functions/Classes Added:** + - Function/Class 1 - Function/Class 2 - Function/Class 3 -... + ... **Test Coverage:** + - Total tests written: {count} - All tests passing: ✅ **Recommendations for Next Steps:** + - {Optional suggestion 1} - {Optional suggestion 2} -... + ... ``` + + ``` fix/feat/chore/test/refactor: Short description of the change (max 50 characters) @@ -213,6 +250,7 @@ DON'T include references to the plan or phase numbers in the commit message. The CRITICAL PAUSE POINTS - You must stop and wait for user input at: + 1. After presenting the plan (before starting implementation) 2. After each phase is reviewed and commit message is provided (before proceeding to next phase) 3. After plan completion document is created @@ -222,6 +260,7 @@ DO NOT proceed past these points without explicit user confirmation. Track your progress through the workflow: + - **Current Phase**: Planning / Implementation / Review / Complete - **Plan Phases**: {Current Phase Number} of {Total Phases} - **Last Action**: {What was just completed} diff --git a/.github/agents/code-review-subagent.agent.md b/.github/agents/code-review-subagent.agent.md index 739433e..3688f27 100644 --- a/.github/agents/code-review-subagent.agent.md +++ b/.github/agents/code-review-subagent.agent.md @@ -3,14 +3,17 @@ description: 'Review code changes from a completed implementation phase.' tools: ['search', 'usages', 'problems', 'changes'] # model: Claude Sonnet 4.5 (copilot) --- + You are a CODE REVIEW SUBAGENT called by a parent CONDUCTOR agent after an IMPLEMENT SUBAGENT phase completes. Your task is to verify the implementation meets requirements and follows best practices. CRITICAL: You receive context from the parent agent including: + - The phase objective and implementation steps - Files that were modified/created - The intended behavior and acceptance criteria + 1. **Analyze Changes**: Review the code changes using #changes, #usages, and #problems to understand what was implemented. 2. **Verify Implementation**: Check that: @@ -27,9 +30,10 @@ CRITICAL: You receive context from the parent agent including: - **Issues**: Problems found (if any, with severity: CRITICAL, MAJOR, MINOR) - **Recommendations**: Specific, actionable suggestions for improvements - **Next Steps**: What should happen next (approve and continue, or revise) - + + ## Code Review: {Phase Name} **Status:** {APPROVED | NEEDS_REVISION | FAILED} @@ -37,16 +41,19 @@ CRITICAL: You receive context from the parent agent including: **Summary:** {Brief assessment of implementation quality} **Strengths:** + - {What was done well} - {Good practices followed} **Issues Found:** {if none, say "None"} + - **[{CRITICAL|MAJOR|MINOR}]** {Issue description with file/line reference} **Recommendations:** + - {Specific suggestion for improvement} **Next Steps:** {What the CONDUCTOR should do next} -Keep feedback concise, specific, and actionable. Focus on blocking issues vs. nice-to-haves. Reference specific files, functions, and lines where relevant. \ No newline at end of file +Keep feedback concise, specific, and actionable. Focus on blocking issues vs. nice-to-haves. Reference specific files, functions, and lines where relevant. diff --git a/.github/agents/implement-subagent.agent.md b/.github/agents/implement-subagent.agent.md index 0b8cd5f..f3d3735 100644 --- a/.github/agents/implement-subagent.agent.md +++ b/.github/agents/implement-subagent.agent.md @@ -1,19 +1,35 @@ --- description: 'Execute implementation tasks delegated by the CONDUCTOR agent.' -tools: ['edit', 'search', 'runCommands', 'runTasks', 'usages', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo', 'todos'] +tools: + [ + 'edit', + 'search', + 'runCommands', + 'runTasks', + 'usages', + 'problems', + 'changes', + 'testFailure', + 'fetch', + 'githubRepo', + 'todos', + ] # model: Claude Haiku 4.5 (copilot) --- + You are an IMPLEMENTATION SUBAGENT. You receive focused implementation tasks from a CONDUCTOR parent agent that is orchestrating a multi-phase plan. **Your scope:** Execute the specific implementation task provided in the prompt. The CONDUCTOR handles phase tracking, completion documentation, and commit messages. **Core workflow:** + 1. **Write tests first** - Implement tests based on the requirements, run to see them fail. Follow strict TDD principles. 2. **Write minimum code** - Implement only what's needed to pass the tests 3. **Verify** - Run tests to confirm they pass 4. **Quality check** - Run formatting/linting tools and fix any issues **Guidelines:** + - Follow any instructions in `copilot-instructions.md` or `AGENT.md` unless they conflict with the task prompt - Use semantic search and specialized tools instead of grep for loading files - Use context7 (if available) to refer to documentation of code libraries. @@ -26,8 +42,9 @@ STOP and present 2-3 options with pros/cons. Wait for selection before proceedin **Task completion:** When you've finished the implementation task: + 1. Summarize what was implemented 2. Confirm all tests pass 3. Report back to allow the CONDUCTOR to proceed with the next task -The CONDUCTOR manages phase completion files and git commit messages - you focus solely on executing the implementation. \ No newline at end of file +The CONDUCTOR manages phase completion files and git commit messages - you focus solely on executing the implementation. diff --git a/.github/agents/planning-subagent.agent.md b/.github/agents/planning-subagent.agent.md index 8e7e938..f0b964a 100644 --- a/.github/agents/planning-subagent.agent.md +++ b/.github/agents/planning-subagent.agent.md @@ -4,6 +4,7 @@ argument-hint: Research goal or problem statement tools: ['search', 'usages', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo'] # model: Claude Sonnet 4.5 (copilot) --- + You are a PLANNING SUBAGENT called by a parent CONDUCTOR agent. Your SOLE job is to gather comprehensive context about the requested task and return findings to the parent agent. DO NOT write plans, implement code, or pause for user feedback. @@ -28,20 +29,22 @@ Your SOLE job is to gather comprehensive context about the requested task and re - Note patterns, conventions, or constraints - Suggest 2-3 implementation approaches if multiple options exist - Flag any uncertainties or missing information - + + - Work autonomously without pausing for feedback - Prioritize breadth over depth initially, then drill down - Document file paths, function names, and line numbers - Note existing tests and testing patterns - Identify similar implementations in the codebase - Stop when you have actionable context, not 100% certainty - + Return a structured summary with: + - **Relevant Files:** List with brief descriptions - **Key Functions/Classes:** Names and locations - **Patterns/Conventions:** What the codebase follows - **Implementation Options:** 2-3 approaches if applicable -- **Open Questions:** What remains unclear (if any) \ No newline at end of file +- **Open Questions:** What remains unclear (if any) diff --git a/.yarnrc.yml b/.yarnrc.yml index 5c33c72..0a893a9 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -5,9 +5,9 @@ nodeLinker: node-modules # Workaround para Yarn PnP + Prisma: declarar la dependencia virtual `.prisma` # para que `@prisma/client` pueda resolver sus binarios/artefactos. packageExtensions: - "@prisma/client@*": + '@prisma/client@*': peerDependenciesMeta: - ".prisma": + '.prisma': optional: true dependencies: - ".prisma": "*" + '.prisma': '*' diff --git a/README.md b/README.md index 9a807c4..1855d41 100644 --- a/README.md +++ b/README.md @@ -13,28 +13,28 @@ Quasar es una aplicación web para al gestión de una biblioteca personal de vid ## Otros proyectos relacionados, para coger ideas y funcionalidades -| Herramienta | Categoría | Descripción | Features Destacadas | Ideal Para | Enlace Oficial | -|-----------------------|-------------------------------|-------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------| -| RomM | Gestor de ROMs y Metadatos | Gestor self-hosted de ROMs con interfaz web moderna. | Escanea, enriquece y navega por colecciones de juegos. Obtiene metadatos de IGDB, Screenscraper y MobyGames. Descarga automática de carátulas y fanarts. | Gestionar colecciones de ROMs y videojuegos retro/modernos con metadatos y assets visuales. | [GitHub - RomM](https://github.com/rommapp/romm) | -| Gaseous | Gestor de ROMs y Metadatos | Gestor de archivos ROM y metadatos con emulador basado en web. | Gestión de metadatos y archivos ROM. Emulador integrado accesible desde navegador. | Usuarios que buscan una solución todo-en-uno para ROMs y emulación web. | [GitHub - Gaseous](https://github.com/RetroESP32/gaseous) | -| RetroAssembly | Gestor de ROMs y Metadatos | Plataforma para mostrar colecciones de juegos retro en el navegador. | Interfaz web para visualizar y organizar juegos retro. | Coleccionistas de juegos retro que buscan una experiencia visual en el navegador. | [GitHub - RetroAssembly](https://github.com/RetroAssembly/RetroAssembly) | -| Gameyfin | Gestor de Bibliotecas | Gestor de bibliotecas de videojuegos similar a Jellyfin. | Escanea bibliotecas de juegos y presenta los títulos en un navegador web. Organiza juegos de Steam, Epic, GOG y otras fuentes. Interfaz limpia y soporte para plugins. | Gestionar y compartir juegos digitales con amigos o familia. | [GitHub - Gameyfin](https://github.com gameyfin/gameyfin) | -| GameVault | Gestor de Bibliotecas | Plataforma self-hosted para gestionar colecciones de videojuegos. | Integración con IGDB para metadatos y carátulas. Clasificación por edades y personalización de metadatos. Sistema de plugins. | Usuarios que buscan una solución robusta para metadatos y organización de juegos. | [GameVault](https://gamevau.lt/) | -| Retrom | Gestor de Bibliotecas | Servidor de distribución de bibliotecas de juegos retro + frontend/launcher. | Distribución y lanzamiento de juegos retro desde tu propio servidor. Interfaz personalizable. | Colecciones de juegos retro y distribución centralizada. | [GitHub - Retrom](https://github.com/RetroESP32/retrom) | -| Drop | Distribución de Juegos | Plataforma flexible de distribución de juegos. | Permite distribuir y gestionar juegos de forma centralizada. | Distribución de juegos en redes locales o comunidades. | [GitHub - Drop](https://github.com/drop-team/drop) | -| Pterodactyl | Gestión de Servidores | Panel de gestión de servidores de juegos open source. | Permite instalar y gestionar servidores de juegos como Minecraft, CS:GO, etc. Interfaz web moderna y soporte para múltiples usuarios. | Administrar servidores de juegos para comunidades o grupos. | [Pterodactyl](https://pterodactyl.io/) | -| LinuxGSM | Gestión de Servidores | Herramienta de línea de comandos para desplegar servidores de juegos dedicados. | Soporte para más de 100 servidores de juegos. Automatización de instalación y actualización. | Usuarios avanzados que prefieren la línea de comandos. | [LinuxGSM](https://linuxgsm.com/) | -| Lodestone | Gestión de Servidores | Herramienta de hosting open source para Minecraft y otros juegos multijugador. | Gestión simplificada de servidores de Minecraft y otros juegos. | Hosting de servidores de Minecraft y juegos similares. | [GitHub - Lodestone](https://github.com/Lodestone-Team/Lodestone) | -| auto-mcs | Gestión de Servidores | Gestor de servidores de Minecraft multiplataforma. | Automatización de la gestión de servidores de Minecraft. | Usuarios que buscan una solución sencilla para Minecraft. | [GitHub - auto-mcs](https://github.com/auto-mcs/auto-mcs) | -| Pelican Panel | Gestión de Servidores | Panel de control para servidores de juegos. | Interfaz web para gestionar servidores de juegos. | Alternativa a Pterodactyl, con enfoque en simplicidad. | [GitHub - Pelican Panel](https://github.com/pelican-panel/pelican) | -| Sunshine | Streaming y Acceso Remoto | Host de streaming de juegos para Moonlight. | Permite transmitir juegos desde tu PC a otros dispositivos. | Jugar en remoto desde tablets, móviles o TVs. | [GitHub - Sunshine](https://github.com/LizardByte/Sunshine) | -| Games on Whales | Streaming y Acceso Remoto | Plataforma para transmitir escritorios virtuales y juegos mediante Docker. | Streaming de juegos y escritorios virtuales. | Usuarios que buscan una solución de streaming basada en Docker. | [GitHub - Games on Whales](https://github.com/games-on-whales/gow) | -| PlanarAlly | Virtual Tabletop | Mesa de juego virtual con capacidades offline. | Soporte para juegos de rol y tablero. | Jugadores de rol que buscan una mesa virtual self-hosted. | [GitHub - PlanarAlly](https://github.com/PlanarAlly/planarally) | -| Foundry Virtual Tabletop | Virtual Tabletop | Plataforma moderna para juegos de rol. | Herramientas avanzadas para masters y jugadores. | Comunidades de juegos de rol que buscan una solución profesional. | [Foundry Virtual Tabletop](https://foundryvtt.com/) | -| LANCommander | Distribución de Juegos | Plataforma open source para distribución digital de videojuegos. | Permite distribuir juegos en una red local. | Distribución de juegos en LAN o comunidades pequeñas. | [GitHub - LANCommander](https://github.com/LANCommander/LANCommander) | -| Fireshare | Distribución de Juegos | Comparte clips de juegos, videos u otros medios mediante enlaces únicos. | Comparte contenido multimedia de forma sencilla. | Compartir capturas o videos de juegos con amigos. | [GitHub - Fireshare](https://github.com/fireshare/fireshare) | -| Crafty Controller | Herramientas para Minecraft | Panel de control y lanzador para servidores de Minecraft. | Gestión simplificada de servidores de Minecraft. | Administrar servidores de Minecraft de forma visual. | [GitHub - Crafty Controller](https://github.com/crafty-controller/crafty-controller) | -| Steam Headless | Herramientas para Steam | Servidor remoto de Steam sin cabeza (headless) mediante Docker. | Permite gestionar juegos de Steam en un servidor remoto. | Usuarios que quieren acceder a su biblioteca de Steam desde un servidor. | [GitHub - Steam Headless](https://github.com/steamheadless/steamheadless) | +| Herramienta | Categoría | Descripción | Features Destacadas | Ideal Para | Enlace Oficial | +| ------------------------ | --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| RomM | Gestor de ROMs y Metadatos | Gestor self-hosted de ROMs con interfaz web moderna. | Escanea, enriquece y navega por colecciones de juegos. Obtiene metadatos de IGDB, Screenscraper y MobyGames. Descarga automática de carátulas y fanarts. | Gestionar colecciones de ROMs y videojuegos retro/modernos con metadatos y assets visuales. | [GitHub - RomM](https://github.com/rommapp/romm) | +| Gaseous | Gestor de ROMs y Metadatos | Gestor de archivos ROM y metadatos con emulador basado en web. | Gestión de metadatos y archivos ROM. Emulador integrado accesible desde navegador. | Usuarios que buscan una solución todo-en-uno para ROMs y emulación web. | [GitHub - Gaseous](https://github.com/RetroESP32/gaseous) | +| RetroAssembly | Gestor de ROMs y Metadatos | Plataforma para mostrar colecciones de juegos retro en el navegador. | Interfaz web para visualizar y organizar juegos retro. | Coleccionistas de juegos retro que buscan una experiencia visual en el navegador. | [GitHub - RetroAssembly](https://github.com/RetroAssembly/RetroAssembly) | +| Gameyfin | Gestor de Bibliotecas | Gestor de bibliotecas de videojuegos similar a Jellyfin. | Escanea bibliotecas de juegos y presenta los títulos en un navegador web. Organiza juegos de Steam, Epic, GOG y otras fuentes. Interfaz limpia y soporte para plugins. | Gestionar y compartir juegos digitales con amigos o familia. | [GitHub - Gameyfin](https://github.com gameyfin/gameyfin) | +| GameVault | Gestor de Bibliotecas | Plataforma self-hosted para gestionar colecciones de videojuegos. | Integración con IGDB para metadatos y carátulas. Clasificación por edades y personalización de metadatos. Sistema de plugins. | Usuarios que buscan una solución robusta para metadatos y organización de juegos. | [GameVault](https://gamevau.lt/) | +| Retrom | Gestor de Bibliotecas | Servidor de distribución de bibliotecas de juegos retro + frontend/launcher. | Distribución y lanzamiento de juegos retro desde tu propio servidor. Interfaz personalizable. | Colecciones de juegos retro y distribución centralizada. | [GitHub - Retrom](https://github.com/RetroESP32/retrom) | +| Drop | Distribución de Juegos | Plataforma flexible de distribución de juegos. | Permite distribuir y gestionar juegos de forma centralizada. | Distribución de juegos en redes locales o comunidades. | [GitHub - Drop](https://github.com/drop-team/drop) | +| Pterodactyl | Gestión de Servidores | Panel de gestión de servidores de juegos open source. | Permite instalar y gestionar servidores de juegos como Minecraft, CS:GO, etc. Interfaz web moderna y soporte para múltiples usuarios. | Administrar servidores de juegos para comunidades o grupos. | [Pterodactyl](https://pterodactyl.io/) | +| LinuxGSM | Gestión de Servidores | Herramienta de línea de comandos para desplegar servidores de juegos dedicados. | Soporte para más de 100 servidores de juegos. Automatización de instalación y actualización. | Usuarios avanzados que prefieren la línea de comandos. | [LinuxGSM](https://linuxgsm.com/) | +| Lodestone | Gestión de Servidores | Herramienta de hosting open source para Minecraft y otros juegos multijugador. | Gestión simplificada de servidores de Minecraft y otros juegos. | Hosting de servidores de Minecraft y juegos similares. | [GitHub - Lodestone](https://github.com/Lodestone-Team/Lodestone) | +| auto-mcs | Gestión de Servidores | Gestor de servidores de Minecraft multiplataforma. | Automatización de la gestión de servidores de Minecraft. | Usuarios que buscan una solución sencilla para Minecraft. | [GitHub - auto-mcs](https://github.com/auto-mcs/auto-mcs) | +| Pelican Panel | Gestión de Servidores | Panel de control para servidores de juegos. | Interfaz web para gestionar servidores de juegos. | Alternativa a Pterodactyl, con enfoque en simplicidad. | [GitHub - Pelican Panel](https://github.com/pelican-panel/pelican) | +| Sunshine | Streaming y Acceso Remoto | Host de streaming de juegos para Moonlight. | Permite transmitir juegos desde tu PC a otros dispositivos. | Jugar en remoto desde tablets, móviles o TVs. | [GitHub - Sunshine](https://github.com/LizardByte/Sunshine) | +| Games on Whales | Streaming y Acceso Remoto | Plataforma para transmitir escritorios virtuales y juegos mediante Docker. | Streaming de juegos y escritorios virtuales. | Usuarios que buscan una solución de streaming basada en Docker. | [GitHub - Games on Whales](https://github.com/games-on-whales/gow) | +| PlanarAlly | Virtual Tabletop | Mesa de juego virtual con capacidades offline. | Soporte para juegos de rol y tablero. | Jugadores de rol que buscan una mesa virtual self-hosted. | [GitHub - PlanarAlly](https://github.com/PlanarAlly/planarally) | +| Foundry Virtual Tabletop | Virtual Tabletop | Plataforma moderna para juegos de rol. | Herramientas avanzadas para masters y jugadores. | Comunidades de juegos de rol que buscan una solución profesional. | [Foundry Virtual Tabletop](https://foundryvtt.com/) | +| LANCommander | Distribución de Juegos | Plataforma open source para distribución digital de videojuegos. | Permite distribuir juegos en una red local. | Distribución de juegos en LAN o comunidades pequeñas. | [GitHub - LANCommander](https://github.com/LANCommander/LANCommander) | +| Fireshare | Distribución de Juegos | Comparte clips de juegos, videos u otros medios mediante enlaces únicos. | Comparte contenido multimedia de forma sencilla. | Compartir capturas o videos de juegos con amigos. | [GitHub - Fireshare](https://github.com/fireshare/fireshare) | +| Crafty Controller | Herramientas para Minecraft | Panel de control y lanzador para servidores de Minecraft. | Gestión simplificada de servidores de Minecraft. | Administrar servidores de Minecraft de forma visual. | [GitHub - Crafty Controller](https://github.com/crafty-controller/crafty-controller) | +| Steam Headless | Herramientas para Steam | Servidor remoto de Steam sin cabeza (headless) mediante Docker. | Permite gestionar juegos de Steam en un servidor remoto. | Usuarios que quieren acceder a su biblioteca de Steam desde un servidor. | [GitHub - Steam Headless](https://github.com/steamheadless/steamheadless) | ## Dependencias nativas para tests de integración @@ -43,19 +43,20 @@ requieren herramientas nativas instaladas en el sistema donde se ejecuten los tests (local o CI). A continuación está la lista mínima y cómo instalarlas: - `7z` / `p7zip` — necesario para extraer/leer ZIP y 7z. - - Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar` - - macOS (Homebrew): `brew install p7zip` + - Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar` + - macOS (Homebrew): `brew install p7zip` - `chdman` — herramienta de MAME para manejar archivos CHD (opcional, - requerida para tests que trabajen con imágenes CHD). - - Debian/Ubuntu: intentar `sudo apt install -y mame-tools` o `sudo apt install -y mame`. - - macOS (Homebrew): `brew install mame` - - Si no hay paquete disponible, descargar o compilar MAME/CHDTools desde - las fuentes oficiales. + requerida para tests que trabajen con imágenes CHD). + - Debian/Ubuntu: intentar `sudo apt install -y mame-tools` o `sudo apt install -y mame`. + - macOS (Homebrew): `brew install mame` + - Si no hay paquete disponible, descargar o compilar MAME/CHDTools desde + las fuentes oficiales. Notas: + - En CI se intentará instalar estas herramientas cuando sea posible; si no - están disponibles los tests de integración que dependan de ellas pueden - configurarse para ejecutarse condicionalmente. + están disponibles los tests de integración que dependan de ellas pueden + configurarse para ejecutarse condicionalmente. - La variable de entorno `INTEGRATION=1` controla si se ejecutan pruebas - más pesadas y dependientes de binarios. + más pesadas y dependientes de binarios. diff --git a/backend/package.json b/backend/package.json index 231341d..7b4e882 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,7 +25,9 @@ "@prisma/client": "6.19.2", "dotenv": "^16.0.0", "fastify": "^4.28.0", - "pino": "^8.0.0" + "pino": "^8.0.0", + "undici": "^5.18.0", + "zod": "^3.22.0" }, "devDependencies": { "@types/node": "^18.0.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 71c5ed5..4ab20bd 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,7 @@ import helmet from '@fastify/helmet'; import rateLimit from '@fastify/rate-limit'; import healthRoutes from './routes/health'; import importRoutes from './routes/import'; +import gamesRoutes from './routes/games'; export function buildApp(): FastifyInstance { const app: FastifyInstance = Fastify({ @@ -15,6 +16,7 @@ export function buildApp(): FastifyInstance { void app.register(rateLimit, { max: 1000, timeWindow: '1 minute' }); void app.register(healthRoutes, { prefix: '/api' }); void app.register(importRoutes, { prefix: '/api' }); + void app.register(gamesRoutes, { prefix: '/api' }); return app; } diff --git a/backend/src/controllers/gamesController.ts b/backend/src/controllers/gamesController.ts new file mode 100644 index 0000000..6d9b7dc --- /dev/null +++ b/backend/src/controllers/gamesController.ts @@ -0,0 +1,180 @@ +import { prisma } from '../plugins/prisma'; +import { CreateGameInput, UpdateGameInput } from '../validators/gameValidator'; +import { Prisma } from '@prisma/client'; + +export class GamesController { + /** + * Listar todos los juegos con sus plataformas y compras + */ + static async listGames() { + return await prisma.game.findMany({ + include: { + gamePlatforms: { + include: { + platform: true, + }, + }, + purchases: true, + }, + orderBy: { + title: 'asc', + }, + }); + } + + /** + * Crear un juego nuevo + */ + static async createGame(input: CreateGameInput) { + const { title, platformId, description, priceCents, currency, store, date, condition } = input; + + // Generar slug basado en el título + const slug = title + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, ''); + + const gameData: Prisma.GameCreateInput = { + title, + slug: `${slug}-${Date.now()}`, // Hacer slug único agregando timestamp + description: description || null, + }; + + // Si se proporciona una plataforma, crearla en gamePlatforms + if (platformId) { + gameData.gamePlatforms = { + create: { + platformId, + }, + }; + } + + // Si se proporciona precio, crear en purchases + if (priceCents) { + gameData.purchases = { + create: { + priceCents, + currency: currency || 'USD', + store: store || null, + date: date ? new Date(date) : new Date(), + }, + }; + } + + return await prisma.game.create({ + data: gameData, + include: { + gamePlatforms: { + include: { + platform: true, + }, + }, + purchases: true, + }, + }); + } + + /** + * Actualizar un juego existente + */ + static async updateGame(id: string, input: UpdateGameInput) { + const { title, platformId, description, priceCents, currency, store, date } = input; + + const updateData: Prisma.GameUpdateInput = {}; + + if (title !== undefined) { + updateData.title = title; + // Regenerar slug si cambia el título + const slug = title + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, ''); + updateData.slug = `${slug}-${Date.now()}`; + } + + if (description !== undefined) { + updateData.description = description; + } + + const game = await prisma.game.update({ + where: { id }, + data: updateData, + include: { + gamePlatforms: { + include: { + platform: true, + }, + }, + purchases: true, + }, + }); + + // Si se actualiza plataforma, sincronizar + if (platformId !== undefined) { + // Eliminar relaciones antiguas + await prisma.gamePlatform.deleteMany({ + where: { gameId: id }, + }); + + // Crear nueva relación si se proporcionó platformId + if (platformId) { + await prisma.gamePlatform.create({ + data: { + gameId: id, + platformId, + }, + }); + } + } + + // Si se actualiza precio, agregar nueva compra (crear histórico) + if (priceCents !== undefined) { + await prisma.purchase.create({ + data: { + gameId: id, + priceCents, + currency: currency || 'USD', + store: store || null, + date: date ? new Date(date) : new Date(), + }, + }); + } + + // Retornar el juego actualizado + return await prisma.game.findUniqueOrThrow({ + where: { id }, + include: { + gamePlatforms: { + include: { + platform: true, + }, + }, + purchases: true, + }, + }); + } + + /** + * Eliminar un juego (y sus relaciones en cascada) + */ + static async deleteGame(id: string) { + // Validar que el juego existe + const game = await prisma.game.findUnique({ where: { id } }); + if (!game) { + throw new Error('Juego no encontrado'); + } + + // Eliminar todas las relaciones (Prisma maneja cascada según schema) + await prisma.game.delete({ + where: { id }, + }); + + return { message: 'Juego eliminado correctamente' }; + } +} + +/** + * Metadatos: + * Autor: GitHub Copilot + * Última actualización: 2026-02-11 + */ diff --git a/backend/src/plugins/prisma.ts b/backend/src/plugins/prisma.ts index 30659ab..57bba56 100644 --- a/backend/src/plugins/prisma.ts +++ b/backend/src/plugins/prisma.ts @@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); export default prisma; +export { prisma }; /** * Metadatos: diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts new file mode 100644 index 0000000..d258af2 --- /dev/null +++ b/backend/src/routes/games.ts @@ -0,0 +1,91 @@ +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { GamesController } from '../controllers/gamesController'; +import { createGameSchema, updateGameSchema } from '../validators/gameValidator'; +import { ZodError } from 'zod'; + +async function gamesRoutes(app: FastifyInstance) { + /** + * GET /api/games + * Listar todos los juegos + */ + app.get<{ Reply: any[] }>('/games', async (request, reply) => { + const games = await GamesController.listGames(); + return reply.code(200).send(games); + }); + + /** + * POST /api/games + * Crear un nuevo juego + */ + app.post<{ Body: any; Reply: any }>('/games', async (request, reply) => { + try { + // Validar entrada con Zod + const validated = createGameSchema.parse(request.body); + const game = await GamesController.createGame(validated); + 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; + } + }); + + /** + * PUT /api/games/:id + * Actualizar un juego existente + */ + app.put<{ Params: { id: string }; Body: any; Reply: any }>( + '/games/:id', + async (request, reply) => { + try { + // Validar entrada con Zod + const validated = updateGameSchema.parse(request.body); + const game = await GamesController.updateGame(request.params.id, validated); + return reply.code(200).send(game); + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ + error: 'Validación fallida', + details: error.errors, + }); + } + if (error instanceof Error && error.message.includes('not found')) { + return reply.code(404).send({ + error: 'Juego no encontrado', + }); + } + throw error; + } + } + ); + + /** + * DELETE /api/games/:id + * Eliminar un juego + */ + app.delete<{ Params: { id: string }; Reply: any }>('/games/:id', async (request, reply) => { + try { + await GamesController.deleteGame(request.params.id); + return reply.code(204).send(); + } catch (error) { + if (error instanceof Error && error.message.includes('no encontrado')) { + return reply.code(404).send({ + error: 'Juego no encontrado', + }); + } + throw error; + } + }); +} + +export default gamesRoutes; + +/** + * Metadatos: + * Autor: GitHub Copilot + * Última actualización: 2026-02-11 + */ diff --git a/backend/src/services/igdbClient.ts b/backend/src/services/igdbClient.ts new file mode 100644 index 0000000..d035e07 --- /dev/null +++ b/backend/src/services/igdbClient.ts @@ -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 { + 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 { + 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; + + 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 { + 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; + + 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 + */ diff --git a/backend/src/services/metadataService.ts b/backend/src/services/metadataService.ts new file mode 100644 index 0000000..983a379 --- /dev/null +++ b/backend/src/services/metadataService.ts @@ -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 { + 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 + */ diff --git a/backend/src/services/rawgClient.ts b/backend/src/services/rawgClient.ts new file mode 100644 index 0000000..fae2973 --- /dev/null +++ b/backend/src/services/rawgClient.ts @@ -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 { + 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 { + 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 + */ diff --git a/backend/src/services/thegamesdbClient.ts b/backend/src/services/thegamesdbClient.ts new file mode 100644 index 0000000..5bacb37 --- /dev/null +++ b/backend/src/services/thegamesdbClient.ts @@ -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 { + 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 { + 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 + */ diff --git a/backend/src/validators/gameValidator.ts b/backend/src/validators/gameValidator.ts new file mode 100644 index 0000000..95ed623 --- /dev/null +++ b/backend/src/validators/gameValidator.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +// Enum para condiciones (Loose, CIB, New) +export const GameCondition = z.enum(['Loose', 'CIB', 'New']).optional(); + +// Esquema de validación para crear un juego +export const createGameSchema = z.object({ + title: z.string().min(1, 'El título es requerido').trim(), + platformId: z.string().optional(), + description: z.string().optional().nullable(), + priceCents: z.number().int().positive().optional(), + currency: z.string().optional().default('USD'), + store: z.string().optional(), + date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo) + condition: GameCondition, +}); + +// Esquema de validación para actualizar un juego (todos los campos son opcionales) +export const updateGameSchema = z + .object({ + title: z.string().min(1).trim().optional(), + platformId: z.string().optional(), + description: z.string().optional().nullable(), + priceCents: z.number().int().positive().optional(), + currency: z.string().optional(), + store: z.string().optional(), + date: z.string().optional(), // Acepta formato ISO (YYYY-MM-DD o ISO completo) + condition: GameCondition, + }) + .strict(); + +// Tipos TypeScript derivados de los esquemas +export type CreateGameInput = z.infer; +export type UpdateGameInput = z.infer; + +/** + * Metadatos: + * Autor: GitHub Copilot + * Última actualización: 2026-02-11 + */ diff --git a/backend/tests/routes/games.spec.ts b/backend/tests/routes/games.spec.ts new file mode 100644 index 0000000..680db9c --- /dev/null +++ b/backend/tests/routes/games.spec.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { buildApp } from '../../src/app'; +import { FastifyInstance } from 'fastify'; +import { prisma } from '../../src/plugins/prisma'; + +describe('Games API', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = buildApp(); + await app.ready(); + // Limpiar base de datos antes de cada test + await prisma.purchase.deleteMany(); + await prisma.gamePlatform.deleteMany(); + await prisma.game.deleteMany(); + await prisma.platform.deleteMany(); + }); + + afterEach(async () => { + await app.close(); + }); + + describe('GET /api/games', () => { + it('debería devolver una lista vacía cuando no hay juegos', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/games', + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([]); + }); + + it('debería devolver una lista de juegos con todas sus propiedades', async () => { + // Crear un juego de prueba + const platform = await prisma.platform.create({ + data: { name: 'Nintendo', slug: 'nintendo' }, + }); + + const game = await prisma.game.create({ + data: { + title: 'The Legend of Zelda', + slug: 'legend-of-zelda', + description: 'Un videojuego clásico', + gamePlatforms: { + create: { + platformId: platform.id, + }, + }, + purchases: { + create: { + priceCents: 5000, + currency: 'USD', + store: 'eBay', + date: new Date('2025-01-15'), + }, + }, + }, + include: { + gamePlatforms: { + include: { + platform: true, + }, + }, + purchases: true, + }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/games', + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(1); + expect(body[0]).toHaveProperty('id'); + expect(body[0]).toHaveProperty('title'); + }); + }); + + describe('POST /api/games', () => { + it('debería crear un juego válido con todos los campos', async () => { + // Crear plataforma primero + const platform = await prisma.platform.create({ + data: { name: 'Nintendo 64', slug: 'n64' }, + }); + + const payload = { + title: 'Super Mario 64', + platformId: platform.id, + description: 'Notas sobre el juego', + priceCents: 15000, + currency: 'USD', + store: 'Local Shop', + date: '2025-01-20', + condition: 'CIB', + }; + + const res = await app.inject({ + method: 'POST', + url: '/api/games', + payload, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body).toHaveProperty('id'); + expect(body.title).toBe('Super Mario 64'); + expect(body.description).toBe('Notas sobre el juego'); + }); + + it('debería fallar si falta el título (requerido)', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games', + payload: { + platformId: 'non-existing-id', + priceCents: 10000, + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('debería fallar si el título está vacío', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games', + payload: { + title: '', + platformId: 'some-id', + }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('debería crear un juego con solo los campos requeridos', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/games', + payload: { + title: 'Game Title Only', + }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body).toHaveProperty('id'); + expect(body.title).toBe('Game Title Only'); + }); + }); + + describe('PUT /api/games/:id', () => { + it('debería actualizar un juego existente', async () => { + const game = await prisma.game.create({ + data: { + title: 'Original Title', + slug: 'original-title', + }, + }); + + const res = await app.inject({ + method: 'PUT', + url: `/api/games/${game.id}`, + payload: { + title: 'Updated Title', + description: 'Updated description', + }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.title).toBe('Updated Title'); + expect(body.description).toBe('Updated description'); + }); + + it('debería devolver 404 si el juego no existe', async () => { + const res = await app.inject({ + method: 'PUT', + url: '/api/games/non-existing-id', + payload: { + title: 'Some Title', + }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('debería permitir actualización parcial', async () => { + const game = await prisma.game.create({ + data: { + title: 'Original Title', + slug: 'original', + description: 'Original description', + }, + }); + + const res = await app.inject({ + method: 'PUT', + url: `/api/games/${game.id}`, + payload: { + description: 'New description only', + }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.title).toBe('Original Title'); // No cambió + expect(body.description).toBe('New description only'); // Cambió + }); + }); + + describe('DELETE /api/games/:id', () => { + it('debería eliminar un juego existente', async () => { + const game = await prisma.game.create({ + data: { + title: 'Game to Delete', + slug: 'game-to-delete', + }, + }); + + const res = await app.inject({ + method: 'DELETE', + url: `/api/games/${game.id}`, + }); + + expect(res.statusCode).toBe(204); + + // Verificar que el juego fue eliminado + const deletedGame = await prisma.game.findUnique({ + where: { id: game.id }, + }); + expect(deletedGame).toBeNull(); + }); + + it('debería devolver 404 si el juego no existe', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/api/games/non-existing-id', + }); + + expect(res.statusCode).toBe(404); + }); + }); +}); + +/** + * Metadatos: + * Autor: GitHub Copilot + * Última actualización: 2026-02-11 + */ diff --git a/backend/tests/services/metadataService.spec.ts b/backend/tests/services/metadataService.spec.ts new file mode 100644 index 0000000..0f8955e --- /dev/null +++ b/backend/tests/services/metadataService.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/services/igdbClient', () => ({ + searchGames: vi.fn(), + getGameById: vi.fn(), +})); + +vi.mock('../../src/services/rawgClient', () => ({ + searchGames: vi.fn(), + getGameById: vi.fn(), +})); + +vi.mock('../../src/services/thegamesdbClient', () => ({ + searchGames: vi.fn(), + getGameById: vi.fn(), +})); + +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'; + +describe('services/metadataService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('prioriza IGDB cuando hay resultados', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 11, + name: 'Sonic', + slug: 'sonic', + releaseDate: '1991-06-23', + genres: ['Platform'], + coverUrl: 'http://img', + source: 'igdb', + }, + ]); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]); + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const res = await enrichGame({ title: 'Sonic' }); + expect(res).not.toBeNull(); + expect(res?.source).toBe('igdb'); + expect(res?.externalIds.igdb).toBe(11); + expect(res?.title).toBe('Sonic'); + }); + + it('cae a RAWG cuando IGDB no responde resultados', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([ + { + id: 22, + name: 'Sonic (rawg)', + slug: 'sonic-rawg', + releaseDate: '1991-06-23', + genres: ['Platform'], + coverUrl: 'http://img', + source: 'rawg', + }, + ]); + + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const res = await enrichGame({ title: 'Sonic' }); + expect(res).not.toBeNull(); + expect(res?.source).toBe('rawg'); + expect(res?.externalIds.rawg).toBe(22); + }); + + it('retorna null si no hay resultados en ninguna API', async () => { + (igdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + (rawg.searchGames as unknown as ReturnType).mockResolvedValue([]); + (tgdb.searchGames as unknown as ReturnType).mockResolvedValue([]); + + const res = await enrichGame({ title: 'Juego inexistente' }); + expect(res).toBeNull(); + }); +}); diff --git a/backend/tests/setup.ts b/backend/tests/setup.ts new file mode 100644 index 0000000..1c44a42 --- /dev/null +++ b/backend/tests/setup.ts @@ -0,0 +1,10 @@ +import dotenv from 'dotenv'; + +// Cargar variables de entorno desde .env +dotenv.config(); + +/** + * Metadatos: + * Autor: GitHub Copilot + * Última actualización: 2026-02-11 + */ diff --git a/backend/tmp-checksum-rBr280/test.bin b/backend/tmp-checksum-rBr280/test.bin deleted file mode 100644 index 4196589..0000000 --- a/backend/tmp-checksum-rBr280/test.bin +++ /dev/null @@ -1 +0,0 @@ -quasar-stream-test \ No newline at end of file diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index 977faa2..0bcc16c 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -15,5 +15,6 @@ export default defineConfig({ provider: 'c8', reporter: ['text', 'lcov'], }, + setupFiles: ['./tests/setup.ts'], }, }); diff --git a/frontend/package.json b/frontend/package.json index 16b5949..d8fbb6d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,13 +13,17 @@ "format": "prettier --write ." }, "dependencies": { + "@hookform/resolvers": "^3.3.0", "@tanstack/react-query": "^4.34.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.0", + "zod": "^3.22.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.0", "@types/react": "^18.2.21", "@types/react-dom": "^18.2.7", "@vitejs/plugin-react": "^4.0.0", diff --git a/frontend/src/components/games/GameCard.tsx b/frontend/src/components/games/GameCard.tsx new file mode 100644 index 0000000..0f8ad11 --- /dev/null +++ b/frontend/src/components/games/GameCard.tsx @@ -0,0 +1,38 @@ +import { Game } from '../../types/game'; + +interface GameCardProps { + game: Game; + onEdit?: (game: Game) => void; + onDelete?: (id: string) => void; +} + +export default function GameCard({ game, onEdit, onDelete }: GameCardProps): JSX.Element { + return ( +
+

{game.title}

+

{game.slug}

+ {game.description &&

{game.description}

} +

+ Added: {new Date(game.createdAt).toLocaleDateString()} +

+
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/games/GameForm.tsx b/frontend/src/components/games/GameForm.tsx new file mode 100644 index 0000000..a89ee22 --- /dev/null +++ b/frontend/src/components/games/GameForm.tsx @@ -0,0 +1,190 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Game, CreateGameInput } from '../../types/game'; + +const gameFormSchema = z.object({ + title: z.string().min(1, 'Title is required'), + platformId: z.string().min(1, 'Platform is required'), + description: z.string().optional().nullable(), + priceCents: z.number().optional(), + currency: z.string().optional().default('USD'), + store: z.string().optional(), + date: z.string().optional(), + condition: z.enum(['Loose', 'CIB', 'New']).optional(), + notes: z.string().optional().nullable(), +}); + +type GameFormData = z.infer; + +interface GameFormProps { + initialData?: Game; + onSubmit: (data: CreateGameInput | Game) => void | Promise; + isLoading?: boolean; +} + +export default function GameForm({ + initialData, + onSubmit, + isLoading = false, +}: GameFormProps): JSX.Element { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(gameFormSchema), + defaultValues: initialData + ? { + title: initialData.title, + description: initialData.description, + priceCents: undefined, + currency: 'USD', + store: undefined, + date: undefined, + condition: undefined, + notes: undefined, + } + : undefined, + }); + + const onFormSubmit = (data: GameFormData) => { + onSubmit(data as CreateGameInput); + }; + + return ( +
+
+ + + {errors.title &&

{errors.title.message}

} +
+ +
+ + + {errors.platformId &&

{errors.platformId.message}

} +
+ +
+ + +
+ +
+ +