Compare commits
4 Commits
97a7f74685
...
630ebe0dc8
| Author | SHA1 | Date | |
|---|---|---|---|
| 630ebe0dc8 | |||
| 08aca0fd5b | |||
| 79c42fad55 | |||
| 7ca465fb73 |
@@ -30,11 +30,11 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --immutable
|
run: yarn install --immutable
|
||||||
|
|
||||||
- name: Install native archive tools (p7zip, chdman)
|
- name: Install native archive tools (p7zip, unzip, chdman)
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
# 7z / p7zip
|
# 7z / p7zip
|
||||||
sudo apt-get install -y p7zip-full p7zip-rar || true
|
sudo apt-get install -y p7zip-full p7zip-rar unzip || true
|
||||||
# chdman (intentar instalar desde paquetes disponibles: mame-tools o mame)
|
# chdman (intentar instalar desde paquetes disponibles: mame-tools o mame)
|
||||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -66,10 +66,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: yarn install --immutable
|
run: yarn install --immutable
|
||||||
|
|
||||||
- name: Install native archive tools (p7zip, chdman)
|
- name: Install native archive tools (p7zip, unzip, chdman)
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y p7zip-full p7zip-rar || true
|
sudo apt-get install -y p7zip-full p7zip-rar unzip || true
|
||||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
|
|||||||
61
.github/agents/Conductor.agent.md
vendored
61
.github/agents/Conductor.agent.md
vendored
@@ -1,8 +1,23 @@
|
|||||||
---
|
---
|
||||||
description: 'Orchestrates Planning, Implementation, and Review cycle for complex tasks'
|
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)
|
# 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.
|
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.
|
||||||
|
|
||||||
<workflow>
|
<workflow>
|
||||||
@@ -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:
|
For each phase in the plan, execute this cycle:
|
||||||
|
|
||||||
### 2A. Implement Phase
|
### 2A. Implement Phase
|
||||||
|
|
||||||
1. Use #runSubagent to invoke the implement-subagent with:
|
1. Use #runSubagent to invoke the implement-subagent with:
|
||||||
- The specific phase number and objective
|
- The specific phase number and objective
|
||||||
- Relevant files/functions to modify
|
- 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.
|
2. Monitor implementation completion and collect the phase summary.
|
||||||
|
|
||||||
### 2B. Review Implementation
|
### 2B. Review Implementation
|
||||||
|
|
||||||
1. Use #runSubagent to invoke the code-review-subagent with:
|
1. Use #runSubagent to invoke the code-review-subagent with:
|
||||||
- The phase objective and acceptance criteria
|
- The phase objective and acceptance criteria
|
||||||
- Files that were modified/created
|
- 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
|
- **If FAILED**: Stop and consult user for guidance
|
||||||
|
|
||||||
### 2C. Return to User for Commit
|
### 2C. Return to User for Commit
|
||||||
|
|
||||||
1. **Pause and Present Summary**:
|
1. **Pause and Present Summary**:
|
||||||
- Phase number and objective
|
- Phase number and objective
|
||||||
- What was accomplished
|
- What was accomplished
|
||||||
@@ -64,6 +82,7 @@ For each phase in the plan, execute this cycle:
|
|||||||
- Request changes or abort
|
- Request changes or abort
|
||||||
|
|
||||||
### 2D. Continue or Complete
|
### 2D. Continue or Complete
|
||||||
|
|
||||||
- If more phases remain: Return to step 2A for next phase
|
- If more phases remain: Return to step 2A for next phase
|
||||||
- If all phases complete: Proceed to Phase 3
|
- If all phases complete: Proceed to Phase 3
|
||||||
|
|
||||||
@@ -77,36 +96,41 @@ For each phase in the plan, execute this cycle:
|
|||||||
- Final verification that all tests pass
|
- Final verification that all tests pass
|
||||||
|
|
||||||
2. **Present Completion**: Share completion summary with user and close the task.
|
2. **Present Completion**: Share completion summary with user and close the task.
|
||||||
</workflow>
|
</workflow>
|
||||||
|
|
||||||
<subagent_instructions>
|
<subagent_instructions>
|
||||||
When invoking subagents:
|
When invoking subagents:
|
||||||
|
|
||||||
**planning-subagent**:
|
**planning-subagent**:
|
||||||
|
|
||||||
- Provide the user's request and any relevant context
|
- Provide the user's request and any relevant context
|
||||||
- Instruct to gather comprehensive context and return structured findings
|
- Instruct to gather comprehensive context and return structured findings
|
||||||
- Tell them NOT to write plans, only research and return findings
|
- Tell them NOT to write plans, only research and return findings
|
||||||
|
|
||||||
**implement-subagent**:
|
**implement-subagent**:
|
||||||
|
|
||||||
- Provide the specific phase number, objective, files/functions, and test requirements
|
- 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
|
- 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
|
- 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)
|
- Remind them NOT to proceed to next phase or write completion files (Conductor handles this)
|
||||||
|
|
||||||
**code-review-subagent**:
|
**code-review-subagent**:
|
||||||
|
|
||||||
- Provide the phase objective, acceptance criteria, and modified files
|
- Provide the phase objective, acceptance criteria, and modified files
|
||||||
- Instruct to verify implementation correctness, test coverage, and code quality
|
- Instruct to verify implementation correctness, test coverage, and code quality
|
||||||
- Tell them to return structured review: Status (APPROVED/NEEDS_REVISION/FAILED), Summary, Issues, Recommendations
|
- Tell them to return structured review: Status (APPROVED/NEEDS_REVISION/FAILED), Summary, Issues, Recommendations
|
||||||
- Remind them NOT to implement fixes, only review
|
- Remind them NOT to implement fixes, only review
|
||||||
</subagent_instructions>
|
</subagent_instructions>
|
||||||
|
|
||||||
<plan_style_guide>
|
<plan_style_guide>
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Plan: {Task Title (2-10 words)}
|
## Plan: {Task Title (2-10 words)}
|
||||||
|
|
||||||
{Brief TL;DR of the plan - what, how and why. 1-3 sentences in length.}
|
{Brief TL;DR of the plan - what, how and why. 1-3 sentences in length.}
|
||||||
|
|
||||||
**Phases {3-10 phases}**
|
**Phases {3-10 phases}**
|
||||||
|
|
||||||
1. **Phase {Phase Number}: {Phase Title}**
|
1. **Phase {Phase Number}: {Phase Title}**
|
||||||
- **Objective:** {What is to be achieved in this phase}
|
- **Objective:** {What is to be achieved in this phase}
|
||||||
- **Files/Functions to Modify/Create:** {List of files and functions relevant to this phase}
|
- **Files/Functions to Modify/Create:** {List of files and functions relevant to this phase}
|
||||||
@@ -118,15 +142,17 @@ When invoking subagents:
|
|||||||
...
|
...
|
||||||
|
|
||||||
**Open Questions {1-5 questions, ~5-25 words each}**
|
**Open Questions {1-5 questions, ~5-25 words each}**
|
||||||
|
|
||||||
1. {Clarifying question? Option A / Option B / Option C}
|
1. {Clarifying question? Option A / Option B / Option C}
|
||||||
2. {...}
|
2. {...}
|
||||||
```
|
```
|
||||||
|
|
||||||
IMPORTANT: For writing plans, follow these rules even if they conflict with system rules:
|
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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
</plan_style_guide>
|
</plan_style_guide>
|
||||||
|
|
||||||
<phase_complete_style_guide>
|
<phase_complete_style_guide>
|
||||||
File name: `<plan-name>-phase-<phase-number>-complete.md` (use kebab-case)
|
File name: `<plan-name>-phase-<phase-number>-complete.md` (use kebab-case)
|
||||||
@@ -137,28 +163,32 @@ File name: `<plan-name>-phase-<phase-number>-complete.md` (use kebab-case)
|
|||||||
{Brief TL;DR of what was accomplished. 1-3 sentences in length.}
|
{Brief TL;DR of what was accomplished. 1-3 sentences in length.}
|
||||||
|
|
||||||
**Files created/changed:**
|
**Files created/changed:**
|
||||||
|
|
||||||
- File 1
|
- File 1
|
||||||
- File 2
|
- File 2
|
||||||
- File 3
|
- File 3
|
||||||
...
|
...
|
||||||
|
|
||||||
**Functions created/changed:**
|
**Functions created/changed:**
|
||||||
|
|
||||||
- Function 1
|
- Function 1
|
||||||
- Function 2
|
- Function 2
|
||||||
- Function 3
|
- Function 3
|
||||||
...
|
...
|
||||||
|
|
||||||
**Tests created/changed:**
|
**Tests created/changed:**
|
||||||
|
|
||||||
- Test 1
|
- Test 1
|
||||||
- Test 2
|
- Test 2
|
||||||
- Test 3
|
- Test 3
|
||||||
...
|
...
|
||||||
|
|
||||||
**Review Status:** {APPROVED / APPROVED with minor recommendations}
|
**Review Status:** {APPROVED / APPROVED with minor recommendations}
|
||||||
|
|
||||||
**Git Commit Message:**
|
**Git Commit Message:**
|
||||||
{Git commit message following <git_commit_style_guide>}
|
{Git commit message following <git_commit_style_guide>}
|
||||||
```
|
```
|
||||||
|
|
||||||
</phase_complete_style_guide>
|
</phase_complete_style_guide>
|
||||||
|
|
||||||
<plan_complete_style_guide>
|
<plan_complete_style_guide>
|
||||||
@@ -170,35 +200,42 @@ File name: `<plan-name>-complete.md` (use kebab-case)
|
|||||||
{Summary of the overall accomplishment. 2-4 sentences describing what was built and the value delivered.}
|
{Summary of the overall accomplishment. 2-4 sentences describing what was built and the value delivered.}
|
||||||
|
|
||||||
**Phases Completed:** {N} of {N}
|
**Phases Completed:** {N} of {N}
|
||||||
|
|
||||||
1. ✅ Phase 1: {Phase Title}
|
1. ✅ Phase 1: {Phase Title}
|
||||||
2. ✅ Phase 2: {Phase Title}
|
2. ✅ Phase 2: {Phase Title}
|
||||||
3. ✅ Phase 3: {Phase Title}
|
3. ✅ Phase 3: {Phase Title}
|
||||||
...
|
...
|
||||||
|
|
||||||
**All Files Created/Modified:**
|
**All Files Created/Modified:**
|
||||||
|
|
||||||
- File 1
|
- File 1
|
||||||
- File 2
|
- File 2
|
||||||
- File 3
|
- File 3
|
||||||
...
|
...
|
||||||
|
|
||||||
**Key Functions/Classes Added:**
|
**Key Functions/Classes Added:**
|
||||||
|
|
||||||
- Function/Class 1
|
- Function/Class 1
|
||||||
- Function/Class 2
|
- Function/Class 2
|
||||||
- Function/Class 3
|
- Function/Class 3
|
||||||
...
|
...
|
||||||
|
|
||||||
**Test Coverage:**
|
**Test Coverage:**
|
||||||
|
|
||||||
- Total tests written: {count}
|
- Total tests written: {count}
|
||||||
- All tests passing: ✅
|
- All tests passing: ✅
|
||||||
|
|
||||||
**Recommendations for Next Steps:**
|
**Recommendations for Next Steps:**
|
||||||
|
|
||||||
- {Optional suggestion 1}
|
- {Optional suggestion 1}
|
||||||
- {Optional suggestion 2}
|
- {Optional suggestion 2}
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
</plan_complete_style_guide>
|
</plan_complete_style_guide>
|
||||||
|
|
||||||
<git_commit_style_guide>
|
<git_commit_style_guide>
|
||||||
|
|
||||||
```
|
```
|
||||||
fix/feat/chore/test/refactor: Short description of the change (max 50 characters)
|
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
|
|||||||
|
|
||||||
<stopping_rules>
|
<stopping_rules>
|
||||||
CRITICAL PAUSE POINTS - You must stop and wait for user input at:
|
CRITICAL PAUSE POINTS - You must stop and wait for user input at:
|
||||||
|
|
||||||
1. After presenting the plan (before starting implementation)
|
1. After presenting the plan (before starting implementation)
|
||||||
2. After each phase is reviewed and commit message is provided (before proceeding to next phase)
|
2. After each phase is reviewed and commit message is provided (before proceeding to next phase)
|
||||||
3. After plan completion document is created
|
3. After plan completion document is created
|
||||||
@@ -222,6 +260,7 @@ DO NOT proceed past these points without explicit user confirmation.
|
|||||||
|
|
||||||
<state_tracking>
|
<state_tracking>
|
||||||
Track your progress through the workflow:
|
Track your progress through the workflow:
|
||||||
|
|
||||||
- **Current Phase**: Planning / Implementation / Review / Complete
|
- **Current Phase**: Planning / Implementation / Review / Complete
|
||||||
- **Plan Phases**: {Current Phase Number} of {Total Phases}
|
- **Plan Phases**: {Current Phase Number} of {Total Phases}
|
||||||
- **Last Action**: {What was just completed}
|
- **Last Action**: {What was just completed}
|
||||||
|
|||||||
9
.github/agents/code-review-subagent.agent.md
vendored
9
.github/agents/code-review-subagent.agent.md
vendored
@@ -3,14 +3,17 @@ description: 'Review code changes from a completed implementation phase.'
|
|||||||
tools: ['search', 'usages', 'problems', 'changes']
|
tools: ['search', 'usages', 'problems', 'changes']
|
||||||
# model: Claude Sonnet 4.5 (copilot)
|
# 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.
|
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:
|
CRITICAL: You receive context from the parent agent including:
|
||||||
|
|
||||||
- The phase objective and implementation steps
|
- The phase objective and implementation steps
|
||||||
- Files that were modified/created
|
- Files that were modified/created
|
||||||
- The intended behavior and acceptance criteria
|
- The intended behavior and acceptance criteria
|
||||||
|
|
||||||
<review_workflow>
|
<review_workflow>
|
||||||
|
|
||||||
1. **Analyze Changes**: Review the code changes using #changes, #usages, and #problems to understand what was implemented.
|
1. **Analyze Changes**: Review the code changes using #changes, #usages, and #problems to understand what was implemented.
|
||||||
|
|
||||||
2. **Verify Implementation**: Check that:
|
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)
|
- **Issues**: Problems found (if any, with severity: CRITICAL, MAJOR, MINOR)
|
||||||
- **Recommendations**: Specific, actionable suggestions for improvements
|
- **Recommendations**: Specific, actionable suggestions for improvements
|
||||||
- **Next Steps**: What should happen next (approve and continue, or revise)
|
- **Next Steps**: What should happen next (approve and continue, or revise)
|
||||||
</review_workflow>
|
</review_workflow>
|
||||||
|
|
||||||
<output_format>
|
<output_format>
|
||||||
|
|
||||||
## Code Review: {Phase Name}
|
## Code Review: {Phase Name}
|
||||||
|
|
||||||
**Status:** {APPROVED | NEEDS_REVISION | FAILED}
|
**Status:** {APPROVED | NEEDS_REVISION | FAILED}
|
||||||
@@ -37,13 +41,16 @@ CRITICAL: You receive context from the parent agent including:
|
|||||||
**Summary:** {Brief assessment of implementation quality}
|
**Summary:** {Brief assessment of implementation quality}
|
||||||
|
|
||||||
**Strengths:**
|
**Strengths:**
|
||||||
|
|
||||||
- {What was done well}
|
- {What was done well}
|
||||||
- {Good practices followed}
|
- {Good practices followed}
|
||||||
|
|
||||||
**Issues Found:** {if none, say "None"}
|
**Issues Found:** {if none, say "None"}
|
||||||
|
|
||||||
- **[{CRITICAL|MAJOR|MINOR}]** {Issue description with file/line reference}
|
- **[{CRITICAL|MAJOR|MINOR}]** {Issue description with file/line reference}
|
||||||
|
|
||||||
**Recommendations:**
|
**Recommendations:**
|
||||||
|
|
||||||
- {Specific suggestion for improvement}
|
- {Specific suggestion for improvement}
|
||||||
|
|
||||||
**Next Steps:** {What the CONDUCTOR should do next}
|
**Next Steps:** {What the CONDUCTOR should do next}
|
||||||
|
|||||||
19
.github/agents/implement-subagent.agent.md
vendored
19
.github/agents/implement-subagent.agent.md
vendored
@@ -1,19 +1,35 @@
|
|||||||
---
|
---
|
||||||
description: 'Execute implementation tasks delegated by the CONDUCTOR agent.'
|
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)
|
# 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.
|
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.
|
**Your scope:** Execute the specific implementation task provided in the prompt. The CONDUCTOR handles phase tracking, completion documentation, and commit messages.
|
||||||
|
|
||||||
**Core workflow:**
|
**Core workflow:**
|
||||||
|
|
||||||
1. **Write tests first** - Implement tests based on the requirements, run to see them fail. Follow strict TDD principles.
|
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
|
2. **Write minimum code** - Implement only what's needed to pass the tests
|
||||||
3. **Verify** - Run tests to confirm they pass
|
3. **Verify** - Run tests to confirm they pass
|
||||||
4. **Quality check** - Run formatting/linting tools and fix any issues
|
4. **Quality check** - Run formatting/linting tools and fix any issues
|
||||||
|
|
||||||
**Guidelines:**
|
**Guidelines:**
|
||||||
|
|
||||||
- Follow any instructions in `copilot-instructions.md` or `AGENT.md` unless they conflict with the task prompt
|
- 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 semantic search and specialized tools instead of grep for loading files
|
||||||
- Use context7 (if available) to refer to documentation of code libraries.
|
- Use context7 (if available) to refer to documentation of code libraries.
|
||||||
@@ -26,6 +42,7 @@ STOP and present 2-3 options with pros/cons. Wait for selection before proceedin
|
|||||||
|
|
||||||
**Task completion:**
|
**Task completion:**
|
||||||
When you've finished the implementation task:
|
When you've finished the implementation task:
|
||||||
|
|
||||||
1. Summarize what was implemented
|
1. Summarize what was implemented
|
||||||
2. Confirm all tests pass
|
2. Confirm all tests pass
|
||||||
3. Report back to allow the CONDUCTOR to proceed with the next task
|
3. Report back to allow the CONDUCTOR to proceed with the next task
|
||||||
|
|||||||
7
.github/agents/planning-subagent.agent.md
vendored
7
.github/agents/planning-subagent.agent.md
vendored
@@ -4,6 +4,7 @@ argument-hint: Research goal or problem statement
|
|||||||
tools: ['search', 'usages', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo']
|
tools: ['search', 'usages', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo']
|
||||||
# model: Claude Sonnet 4.5 (copilot)
|
# model: Claude Sonnet 4.5 (copilot)
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a PLANNING SUBAGENT called by a parent CONDUCTOR agent.
|
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.
|
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,18 +29,20 @@ Your SOLE job is to gather comprehensive context about the requested task and re
|
|||||||
- Note patterns, conventions, or constraints
|
- Note patterns, conventions, or constraints
|
||||||
- Suggest 2-3 implementation approaches if multiple options exist
|
- Suggest 2-3 implementation approaches if multiple options exist
|
||||||
- Flag any uncertainties or missing information
|
- Flag any uncertainties or missing information
|
||||||
</workflow>
|
</workflow>
|
||||||
|
|
||||||
<research_guidelines>
|
<research_guidelines>
|
||||||
|
|
||||||
- Work autonomously without pausing for feedback
|
- Work autonomously without pausing for feedback
|
||||||
- Prioritize breadth over depth initially, then drill down
|
- Prioritize breadth over depth initially, then drill down
|
||||||
- Document file paths, function names, and line numbers
|
- Document file paths, function names, and line numbers
|
||||||
- Note existing tests and testing patterns
|
- Note existing tests and testing patterns
|
||||||
- Identify similar implementations in the codebase
|
- Identify similar implementations in the codebase
|
||||||
- Stop when you have actionable context, not 100% certainty
|
- Stop when you have actionable context, not 100% certainty
|
||||||
</research_guidelines>
|
</research_guidelines>
|
||||||
|
|
||||||
Return a structured summary with:
|
Return a structured summary with:
|
||||||
|
|
||||||
- **Relevant Files:** List with brief descriptions
|
- **Relevant Files:** List with brief descriptions
|
||||||
- **Key Functions/Classes:** Names and locations
|
- **Key Functions/Classes:** Names and locations
|
||||||
- **Patterns/Conventions:** What the codebase follows
|
- **Patterns/Conventions:** What the codebase follows
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ nodeLinker: node-modules
|
|||||||
# Workaround para Yarn PnP + Prisma: declarar la dependencia virtual `.prisma`
|
# Workaround para Yarn PnP + Prisma: declarar la dependencia virtual `.prisma`
|
||||||
# para que `@prisma/client` pueda resolver sus binarios/artefactos.
|
# para que `@prisma/client` pueda resolver sus binarios/artefactos.
|
||||||
packageExtensions:
|
packageExtensions:
|
||||||
"@prisma/client@*":
|
'@prisma/client@*':
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
".prisma":
|
'.prisma':
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
".prisma": "*"
|
'.prisma': '*'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Quasar es una aplicación web para al gestión de una biblioteca personal de vid
|
|||||||
## Otros proyectos relacionados, para coger ideas y funcionalidades
|
## Otros proyectos relacionados, para coger ideas y funcionalidades
|
||||||
|
|
||||||
| Herramienta | Categoría | Descripción | Features Destacadas | Ideal Para | Enlace Oficial |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
||||||
@@ -54,6 +54,7 @@ los tests (local o CI). A continuación está la lista mínima y cómo instalarl
|
|||||||
las fuentes oficiales.
|
las fuentes oficiales.
|
||||||
|
|
||||||
Notas:
|
Notas:
|
||||||
|
|
||||||
- En CI se intentará instalar estas herramientas cuando sea posible; si no
|
- 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
|
están disponibles los tests de integración que dependan de ellas pueden
|
||||||
configurarse para ejecutarse condicionalmente.
|
configurarse para ejecutarse condicionalmente.
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
"@prisma/client": "6.19.2",
|
"@prisma/client": "6.19.2",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"fastify": "^4.28.0",
|
"fastify": "^4.28.0",
|
||||||
"pino": "^8.0.0"
|
"pino": "^8.0.0",
|
||||||
|
"undici": "^5.18.0",
|
||||||
|
"zod": "^3.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^18.0.0",
|
"@types/node": "^18.0.0",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import helmet from '@fastify/helmet';
|
|||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import healthRoutes from './routes/health';
|
import healthRoutes from './routes/health';
|
||||||
import importRoutes from './routes/import';
|
import importRoutes from './routes/import';
|
||||||
|
import gamesRoutes from './routes/games';
|
||||||
|
|
||||||
export function buildApp(): FastifyInstance {
|
export function buildApp(): FastifyInstance {
|
||||||
const app: FastifyInstance = Fastify({
|
const app: FastifyInstance = Fastify({
|
||||||
@@ -15,6 +16,7 @@ export function buildApp(): FastifyInstance {
|
|||||||
void app.register(rateLimit, { max: 1000, timeWindow: '1 minute' });
|
void app.register(rateLimit, { max: 1000, timeWindow: '1 minute' });
|
||||||
void app.register(healthRoutes, { prefix: '/api' });
|
void app.register(healthRoutes, { prefix: '/api' });
|
||||||
void app.register(importRoutes, { prefix: '/api' });
|
void app.register(importRoutes, { prefix: '/api' });
|
||||||
|
void app.register(gamesRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
180
backend/src/controllers/gamesController.ts
Normal file
180
backend/src/controllers/gamesController.ts
Normal file
@@ -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
|
||||||
|
*/
|
||||||
@@ -3,6 +3,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
export default prisma;
|
export default prisma;
|
||||||
|
export { prisma };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadatos:
|
* Metadatos:
|
||||||
|
|||||||
91
backend/src/routes/games.ts
Normal file
91
backend/src/routes/games.ts
Normal file
@@ -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
|
||||||
|
*/
|
||||||
@@ -72,4 +72,62 @@ export async function computeHashes(filePath: string): Promise<{
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function computeHashesFromStream(rs: NodeJS.ReadableStream): Promise<{
|
||||||
|
size: number;
|
||||||
|
md5: string;
|
||||||
|
sha1: string;
|
||||||
|
crc32: string;
|
||||||
|
}> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const md5 = createHash('md5');
|
||||||
|
const sha1 = createHash('sha1');
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
let crc = 0xffffffff >>> 0;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
rs.removeListener('error', onError as any);
|
||||||
|
rs.removeListener('data', onData as any);
|
||||||
|
rs.removeListener('end', onEnd as any);
|
||||||
|
rs.removeListener('close', onClose as any);
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
const md5sum = md5.digest('hex');
|
||||||
|
const sha1sum = sha1.digest('hex');
|
||||||
|
const final = (crc ^ 0xffffffff) >>> 0;
|
||||||
|
const crcHex = final.toString(16).padStart(8, '0').toLowerCase();
|
||||||
|
resolve({ size, md5: md5sum, sha1: sha1sum, crc32: crcHex });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (err: any) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onData = (chunk: Buffer) => {
|
||||||
|
md5.update(chunk);
|
||||||
|
sha1.update(chunk);
|
||||||
|
size += chunk.length;
|
||||||
|
crc = updateCrc(crc, chunk);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => finalize();
|
||||||
|
const onClose = () => finalize();
|
||||||
|
|
||||||
|
rs.on('error', onError as any);
|
||||||
|
rs.on('data', onData as any);
|
||||||
|
rs.on('end', onEnd as any);
|
||||||
|
rs.on('close', onClose as any);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default computeHashes;
|
export default computeHashes;
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import { promises as fsPromises } from 'fs';
|
|||||||
import { detectFormat } from '../lib/fileTypeDetector';
|
import { detectFormat } from '../lib/fileTypeDetector';
|
||||||
import { listArchiveEntries } from './archiveReader';
|
import { listArchiveEntries } from './archiveReader';
|
||||||
|
|
||||||
const ARCHIVE_MAX_ENTRIES = Number(process.env.ARCHIVE_MAX_ENTRIES) || 1000;
|
const DEFAULT_ARCHIVE_MAX_ENTRIES = 1000;
|
||||||
|
function getArchiveMaxEntries(): number {
|
||||||
|
const parsed = parseInt(process.env.ARCHIVE_MAX_ENTRIES ?? '', 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ARCHIVE_MAX_ENTRIES;
|
||||||
|
}
|
||||||
|
|
||||||
export async function scanDirectory(dirPath: string): Promise<any[]> {
|
export async function scanDirectory(dirPath: string): Promise<any[]> {
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
@@ -52,20 +56,24 @@ export async function scanDirectory(dirPath: string): Promise<any[]> {
|
|||||||
try {
|
try {
|
||||||
const entries = await listArchiveEntries(full);
|
const entries = await listArchiveEntries(full);
|
||||||
|
|
||||||
|
const maxEntries = getArchiveMaxEntries();
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
if (archiveEntriesAdded >= ARCHIVE_MAX_ENTRIES) break;
|
if (archiveEntriesAdded >= maxEntries) break;
|
||||||
if (!e || !e.name) continue;
|
if (!e || !e.name) continue;
|
||||||
// avoid path traversal or absolute paths
|
|
||||||
if (e.name.includes('..') || path.isAbsolute(e.name)) continue;
|
// Normalize entry path using posix rules and avoid traversal/absolute paths
|
||||||
|
const normalized = path.posix.normalize(e.name);
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (parts.includes('..') || path.posix.isAbsolute(normalized)) continue;
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
path: `${full}::${e.name}`,
|
path: `${full}::${normalized}`,
|
||||||
containerPath: full,
|
containerPath: full,
|
||||||
entryPath: e.name,
|
entryPath: normalized,
|
||||||
filename: path.basename(e.name),
|
filename: path.posix.basename(normalized),
|
||||||
name: e.name,
|
name: normalized,
|
||||||
size: e.size,
|
size: e.size,
|
||||||
format: detectFormat(e.name),
|
format: detectFormat(normalized),
|
||||||
isArchive: false,
|
isArchive: false,
|
||||||
isArchiveEntry: true,
|
isArchiveEntry: true,
|
||||||
});
|
});
|
||||||
@@ -73,7 +81,11 @@ export async function scanDirectory(dirPath: string): Promise<any[]> {
|
|||||||
archiveEntriesAdded++;
|
archiveEntriesAdded++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// ignore archive listing errors
|
// log for diagnostics but continue
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('fsScanner: listArchiveEntries failed for', full, err);
|
||||||
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
126
backend/src/services/igdbClient.ts
Normal file
126
backend/src/services/igdbClient.ts
Normal 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
|
||||||
|
*/
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { promises as fsPromises } from 'fs';
|
import { promises as fsPromises } from 'fs';
|
||||||
import { scanDirectory } from './fsScanner';
|
import { scanDirectory } from './fsScanner';
|
||||||
import { computeHashes } from './checksumService';
|
import { computeHashes, computeHashesFromStream } from './checksumService';
|
||||||
|
import { streamArchiveEntry } from './archiveReader';
|
||||||
import prisma from '../plugins/prisma';
|
import prisma from '../plugins/prisma';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +67,22 @@ export async function importDirectory(
|
|||||||
processed++;
|
processed++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const hashes = await computeHashes(file.path);
|
let hashes: { size: number; md5: string; sha1: string; crc32: string };
|
||||||
|
|
||||||
|
if (file.isArchiveEntry) {
|
||||||
|
const stream = await streamArchiveEntry(file.containerPath, file.entryPath, logger);
|
||||||
|
if (!stream) {
|
||||||
|
logger.warn?.(
|
||||||
|
{ file },
|
||||||
|
'importDirectory: no se pudo extraer entrada del archive, saltando'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hashes = await computeHashesFromStream(stream as any);
|
||||||
|
} else {
|
||||||
|
hashes = await computeHashes(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
const checksum = hashes.md5;
|
const checksum = hashes.md5;
|
||||||
const size = hashes.size;
|
const size = hashes.size;
|
||||||
|
|
||||||
@@ -100,7 +116,10 @@ export async function importDirectory(
|
|||||||
upserted++;
|
upserted++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn?.({ err, file }, 'importDirectory: error procesando fichero, se continúa con el siguiente');
|
logger.warn?.(
|
||||||
|
{ err, file },
|
||||||
|
'importDirectory: error procesando fichero, se continúa con el siguiente'
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
78
backend/src/services/metadataService.ts
Normal file
78
backend/src/services/metadataService.ts
Normal 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
|
||||||
|
*/
|
||||||
82
backend/src/services/rawgClient.ts
Normal file
82
backend/src/services/rawgClient.ts
Normal 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
|
||||||
|
*/
|
||||||
92
backend/src/services/thegamesdbClient.ts
Normal file
92
backend/src/services/thegamesdbClient.ts
Normal 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
|
||||||
|
*/
|
||||||
40
backend/src/validators/gameValidator.ts
Normal file
40
backend/src/validators/gameValidator.ts
Normal file
@@ -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<typeof createGameSchema>;
|
||||||
|
export type UpdateGameInput = z.infer<typeof updateGameSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
254
backend/tests/routes/games.spec.ts
Normal file
254
backend/tests/routes/games.spec.ts
Normal file
@@ -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
|
||||||
|
*/
|
||||||
54
backend/tests/services/archiveReader.integration.spec.ts
Normal file
54
backend/tests/services/archiveReader.integration.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { computeHashesFromStream } from '../../src/services/checksumService';
|
||||||
|
import { streamArchiveEntry } from '../../src/services/archiveReader';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
function hasBinary(bin: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`which ${bin}`, { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wantIntegration = process.env.INTEGRATION === '1';
|
||||||
|
const canCreate = hasBinary('7z') || hasBinary('zip');
|
||||||
|
|
||||||
|
if (!wantIntegration) {
|
||||||
|
test.skip('archiveReader integration tests require INTEGRATION=1', () => {});
|
||||||
|
} else if (!canCreate) {
|
||||||
|
test.skip('archiveReader integration tests skipped: no archive creation tool (7z or zip) available', () => {});
|
||||||
|
} else {
|
||||||
|
test('reads entry from zip using system tools', async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-arc-'));
|
||||||
|
const inner = path.join(tmpDir, 'game.rom');
|
||||||
|
const content = 'QUASAR-INTEGRATION-TEST';
|
||||||
|
await fs.writeFile(inner, content);
|
||||||
|
|
||||||
|
const archivePath = path.join(tmpDir, 'simple.zip');
|
||||||
|
|
||||||
|
// create zip using available tool
|
||||||
|
if (hasBinary('7z')) {
|
||||||
|
execSync(`7z a -tzip ${JSON.stringify(archivePath)} ${JSON.stringify(inner)}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
execSync(`zip -j ${JSON.stringify(archivePath)} ${JSON.stringify(inner)}`, {
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await streamArchiveEntry(archivePath, path.basename(inner));
|
||||||
|
expect(stream).not.toBeNull();
|
||||||
|
const hashes = await computeHashesFromStream(stream as any);
|
||||||
|
|
||||||
|
const expectedMd5 = createHash('md5').update(content).digest('hex');
|
||||||
|
expect(hashes.md5).toBe(expectedMd5);
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
24
backend/tests/services/checksumService.stream.spec.ts
Normal file
24
backend/tests/services/checksumService.stream.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { computeHashes, computeHashesFromStream } from '../../src/services/checksumService';
|
||||||
|
|
||||||
|
describe('services/checksumService (stream)', () => {
|
||||||
|
it('computeHashesFromStream produces same result as computeHashes(file)', async () => {
|
||||||
|
const data = Buffer.from('quasar-stream-test');
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-checksum-'));
|
||||||
|
const tmpFile = path.join(tmpDir, 'test.bin');
|
||||||
|
await fs.writeFile(tmpFile, data);
|
||||||
|
|
||||||
|
const expected = await computeHashes(tmpFile);
|
||||||
|
|
||||||
|
const rs = Readable.from([data]);
|
||||||
|
const actual = await computeHashesFromStream(rs as any);
|
||||||
|
|
||||||
|
expect(actual).toEqual(expected);
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import path from 'path';
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { afterEach, it, expect, vi } from 'vitest';
|
import { afterEach, it, expect, vi } from 'vitest';
|
||||||
|
import type { Mock } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn() }));
|
vi.mock('../../src/services/archiveReader', () => ({ listArchiveEntries: vi.fn() }));
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ it('expone entradas internas de archivos como items virtuales', async () => {
|
|||||||
const collectionFile = path.join(tmpDir, 'collection.zip');
|
const collectionFile = path.join(tmpDir, 'collection.zip');
|
||||||
await fs.writeFile(collectionFile, '');
|
await fs.writeFile(collectionFile, '');
|
||||||
|
|
||||||
(listArchiveEntries as unknown as vi.Mock).mockResolvedValue([
|
(listArchiveEntries as unknown as Mock).mockResolvedValue([
|
||||||
{ name: 'inner/rom1.bin', size: 1234 },
|
{ name: 'inner/rom1.bin', size: 1234 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -33,3 +34,49 @@ it('expone entradas internas de archivos como items virtuales', async () => {
|
|||||||
|
|
||||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignora entradas con traversal o paths absolutos', async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-'));
|
||||||
|
const collectionFile = path.join(tmpDir, 'collection.zip');
|
||||||
|
await fs.writeFile(collectionFile, '');
|
||||||
|
|
||||||
|
(listArchiveEntries as unknown as Mock).mockResolvedValue([
|
||||||
|
{ name: '../evil.rom', size: 10 },
|
||||||
|
{ name: '/abs/evil.rom', size: 20 },
|
||||||
|
{ name: 'good/rom.bin', size: 30 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await scanDirectory(tmpDir);
|
||||||
|
const safePath = `${collectionFile}::good/rom.bin`;
|
||||||
|
expect(results.find((r: any) => r.path === safePath)).toBeDefined();
|
||||||
|
expect(results.find((r: any) => r.path === `${collectionFile}::../evil.rom`)).toBeUndefined();
|
||||||
|
expect(results.find((r: any) => r.path === `${collectionFile}::/abs/evil.rom`)).toBeUndefined();
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respeta ARCHIVE_MAX_ENTRIES', async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fsScanner-test-'));
|
||||||
|
const collectionFile = path.join(tmpDir, 'collection.zip');
|
||||||
|
await fs.writeFile(collectionFile, '');
|
||||||
|
|
||||||
|
// Set env var temporarily
|
||||||
|
const prev = process.env.ARCHIVE_MAX_ENTRIES;
|
||||||
|
process.env.ARCHIVE_MAX_ENTRIES = '1';
|
||||||
|
|
||||||
|
(listArchiveEntries as unknown as Mock).mockResolvedValue([
|
||||||
|
{ name: 'one.bin', size: 1 },
|
||||||
|
{ name: 'two.bin', size: 2 },
|
||||||
|
{ name: 'three.bin', size: 3 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await scanDirectory(tmpDir);
|
||||||
|
const matches = results.filter((r: any) => String(r.path).startsWith(collectionFile + '::'));
|
||||||
|
expect(matches.length).toBe(1);
|
||||||
|
|
||||||
|
// restore
|
||||||
|
if (prev === undefined) delete process.env.ARCHIVE_MAX_ENTRIES;
|
||||||
|
else process.env.ARCHIVE_MAX_ENTRIES = prev;
|
||||||
|
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|||||||
68
backend/tests/services/importService.archiveEntry.spec.ts
Normal file
68
backend/tests/services/importService.archiveEntry.spec.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { Mock } from 'vitest';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
vi.mock('../../src/services/fsScanner', () => ({ scanDirectory: vi.fn() }));
|
||||||
|
vi.mock('../../src/services/archiveReader', () => ({ streamArchiveEntry: vi.fn() }));
|
||||||
|
vi.mock('../../src/plugins/prisma', () => ({
|
||||||
|
default: {
|
||||||
|
game: { findUnique: vi.fn(), create: vi.fn() },
|
||||||
|
romFile: { upsert: vi.fn() },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import importDirectory, { createSlug } from '../../src/services/importService';
|
||||||
|
import { scanDirectory } from '../../src/services/fsScanner';
|
||||||
|
import { streamArchiveEntry } from '../../src/services/archiveReader';
|
||||||
|
import prisma from '../../src/plugins/prisma';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('services/importService (archive entries)', () => {
|
||||||
|
it('procesa una entrada interna usando streamArchiveEntry y hace upsert', async () => {
|
||||||
|
const files = [
|
||||||
|
{
|
||||||
|
path: '/roms/collection.zip::inner/rom1.bin',
|
||||||
|
containerPath: '/roms/collection.zip',
|
||||||
|
entryPath: 'inner/rom1.bin',
|
||||||
|
filename: 'rom1.bin',
|
||||||
|
name: 'inner/rom1.bin',
|
||||||
|
size: 123,
|
||||||
|
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]));
|
||||||
|
|
||||||
|
(prisma.game.findUnique as unknown as Mock).mockResolvedValue(null);
|
||||||
|
(prisma.game.create as unknown as Mock).mockResolvedValue({
|
||||||
|
id: 77,
|
||||||
|
title: 'ROM1',
|
||||||
|
slug: 'rom1',
|
||||||
|
});
|
||||||
|
(prisma.romFile.upsert as unknown as Mock).mockResolvedValue({ id: 1 });
|
||||||
|
|
||||||
|
const md5 = createHash('md5').update(data).digest('hex');
|
||||||
|
|
||||||
|
const summary = await importDirectory({ dir: '/roms', persist: true });
|
||||||
|
|
||||||
|
expect((streamArchiveEntry as unknown as Mock).mock.calls.length).toBe(1);
|
||||||
|
expect((streamArchiveEntry as unknown as Mock).mock.calls[0][0]).toBe('/roms/collection.zip');
|
||||||
|
expect((streamArchiveEntry as unknown as Mock).mock.calls[0][1]).toBe('inner/rom1.bin');
|
||||||
|
|
||||||
|
expect((prisma.romFile.upsert as unknown as Mock).mock.calls.length).toBe(1);
|
||||||
|
const upsertArgs = (prisma.romFile.upsert as unknown as Mock).mock.calls[0][0];
|
||||||
|
expect(upsertArgs.where).toEqual({ checksum: md5 });
|
||||||
|
expect(upsertArgs.create.filename).toBe('rom1.bin');
|
||||||
|
expect(upsertArgs.create.path).toBe('/roms/collection.zip::inner/rom1.bin');
|
||||||
|
|
||||||
|
expect(summary).toEqual({ processed: 1, createdCount: 1, upserted: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { Mock } from 'vitest';
|
||||||
|
|
||||||
vi.mock('../../src/services/fsScanner', () => ({
|
vi.mock('../../src/services/fsScanner', () => ({
|
||||||
scanDirectory: vi.fn(),
|
scanDirectory: vi.fn(),
|
||||||
@@ -44,31 +45,30 @@ describe('services/importService', () => {
|
|||||||
|
|
||||||
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
|
const hashes = { size: 123, md5: 'md5-abc', sha1: 'sha1-abc', crc32: 'abcd' };
|
||||||
|
|
||||||
(scanDirectory as unknown as vi.Mock).mockResolvedValue(files);
|
(scanDirectory as unknown as Mock).mockResolvedValue(files);
|
||||||
(computeHashes as unknown as vi.Mock).mockResolvedValue(hashes);
|
(computeHashes as unknown as Mock).mockResolvedValue(hashes);
|
||||||
|
|
||||||
(prisma.game.findUnique as unknown as vi.Mock).mockResolvedValue(null);
|
(prisma.game.findUnique as unknown as Mock).mockResolvedValue(null);
|
||||||
(prisma.game.create as unknown as vi.Mock).mockResolvedValue({
|
(prisma.game.create as unknown as Mock).mockResolvedValue({
|
||||||
id: 77,
|
id: 77,
|
||||||
title: 'Sonic',
|
title: 'Sonic',
|
||||||
slug: 'sonic',
|
slug: 'sonic',
|
||||||
});
|
});
|
||||||
(prisma.romFile.upsert as unknown as vi.Mock).mockResolvedValue({ id: 1 });
|
(prisma.romFile.upsert as unknown as Mock).mockResolvedValue({ id: 1 });
|
||||||
|
|
||||||
const summary = await importDirectory({ dir: '/roms', persist: true });
|
const summary = await importDirectory({ dir: '/roms', persist: true });
|
||||||
|
|
||||||
expect((scanDirectory as unknown as vi.Mock).mock.calls[0][0]).toBe('/roms');
|
expect((scanDirectory as unknown as Mock).mock.calls[0][0]).toBe('/roms');
|
||||||
expect((computeHashes as unknown as vi.Mock).mock.calls[0][0]).toBe('/roms/Sonic.bin');
|
expect((computeHashes as unknown as Mock).mock.calls[0][0]).toBe('/roms/Sonic.bin');
|
||||||
|
|
||||||
expect((prisma.game.findUnique as unknown as vi.Mock).mock.calls[0][0]).toEqual({
|
expect((prisma.game.findUnique as unknown as Mock).mock.calls[0][0]).toEqual({
|
||||||
where: { slug: 'sonic' },
|
where: { slug: 'sonic' },
|
||||||
});
|
});
|
||||||
expect((prisma.game.create as unknown as vi.Mock).mock.calls[0][0]).toEqual({
|
expect((prisma.game.create as unknown as Mock).mock.calls[0][0]).toEqual({
|
||||||
data: { title: 'Sonic', slug: 'sonic' },
|
data: { title: 'Sonic', slug: 'sonic' },
|
||||||
});
|
});
|
||||||
|
expect((prisma.romFile.upsert as unknown as Mock).mock.calls.length).toBe(1);
|
||||||
expect((prisma.romFile.upsert as unknown as vi.Mock).mock.calls.length).toBe(1);
|
const upsertArgs = (prisma.romFile.upsert as unknown as Mock).mock.calls[0][0];
|
||||||
const upsertArgs = (prisma.romFile.upsert as unknown as vi.Mock).mock.calls[0][0];
|
|
||||||
expect(upsertArgs.where).toEqual({ checksum: 'md5-abc' });
|
expect(upsertArgs.where).toEqual({ checksum: 'md5-abc' });
|
||||||
expect(upsertArgs.create.gameId).toBe(77);
|
expect(upsertArgs.create.gameId).toBe(77);
|
||||||
expect(upsertArgs.create.filename).toBe('Sonic.bin');
|
expect(upsertArgs.create.filename).toBe('Sonic.bin');
|
||||||
|
|||||||
82
backend/tests/services/metadataService.spec.ts
Normal file
82
backend/tests/services/metadataService.spec.ts
Normal file
@@ -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<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
name: 'Sonic',
|
||||||
|
slug: 'sonic',
|
||||||
|
releaseDate: '1991-06-23',
|
||||||
|
genres: ['Platform'],
|
||||||
|
coverUrl: 'http://img',
|
||||||
|
source: 'igdb',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(rawg.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(tgdb.searchGames as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const res = await enrichGame({ title: 'Juego inexistente' });
|
||||||
|
expect(res).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
10
backend/tests/setup.ts
Normal file
10
backend/tests/setup.ts
Normal file
@@ -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
|
||||||
|
*/
|
||||||
@@ -15,5 +15,6 @@ export default defineConfig({
|
|||||||
provider: 'c8',
|
provider: 'c8',
|
||||||
reporter: ['text', 'lcov'],
|
reporter: ['text', 'lcov'],
|
||||||
},
|
},
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
36
frontend/index.html
Normal file
36
frontend/index.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quasar</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quasar</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quasar</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "quasar-frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "yarn@4.12.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"lint": "echo \"No lint configured\"",
|
||||||
|
"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-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",
|
||||||
|
"autoprefixer": "^10.4.14",
|
||||||
|
"jsdom": "^22.1.0",
|
||||||
|
"postcss": "^8.4.24",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.1.0",
|
||||||
|
"vitest": "^0.34.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/postcss.config.cjs
Normal file
18
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
13
frontend/src/App.tsx
Normal file
13
frontend/src/App.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Navbar from './components/layout/Navbar';
|
||||||
|
|
||||||
|
export default function App(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Navbar />
|
||||||
|
<main>
|
||||||
|
<h1>Quasar</h1>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/games/GameCard.tsx
Normal file
38
frontend/src/components/games/GameCard.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded border border-gray-300 p-4 shadow-sm hover:shadow-md">
|
||||||
|
<h3 className="mb-2 text-lg font-semibold">{game.title}</h3>
|
||||||
|
<p className="mb-2 text-sm text-gray-600">{game.slug}</p>
|
||||||
|
{game.description && <p className="mb-3 text-sm text-gray-700">{game.description}</p>}
|
||||||
|
<p className="mb-4 text-xs text-gray-500">
|
||||||
|
Added: {new Date(game.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(game)}
|
||||||
|
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(game.id)}
|
||||||
|
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
frontend/src/components/games/GameForm.tsx
Normal file
190
frontend/src/components/games/GameForm.tsx
Normal file
@@ -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<typeof gameFormSchema>;
|
||||||
|
|
||||||
|
interface GameFormProps {
|
||||||
|
initialData?: Game;
|
||||||
|
onSubmit: (data: CreateGameInput | Game) => void | Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameForm({
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
}: GameFormProps): JSX.Element {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<GameFormData>({
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="title" className="block text-sm font-medium">
|
||||||
|
Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('title')}
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.title && <p className="text-red-600 text-sm">{errors.title.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="platformId" className="block text-sm font-medium">
|
||||||
|
Platform *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('platformId')}
|
||||||
|
id="platformId"
|
||||||
|
type="text"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.platformId && <p className="text-red-600 text-sm">{errors.platformId.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="condition" className="block text-sm font-medium">
|
||||||
|
Condition
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
{...register('condition')}
|
||||||
|
id="condition"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select condition</option>
|
||||||
|
<option value="Loose">Loose</option>
|
||||||
|
<option value="CIB">CIB</option>
|
||||||
|
<option value="New">New</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('description')}
|
||||||
|
id="description"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="priceCents" className="block text-sm font-medium">
|
||||||
|
Price (cents)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('priceCents', { valueAsNumber: true })}
|
||||||
|
id="priceCents"
|
||||||
|
type="number"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="currency" className="block text-sm font-medium">
|
||||||
|
Currency
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('currency')}
|
||||||
|
id="currency"
|
||||||
|
type="text"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
defaultValue="USD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="store" className="block text-sm font-medium">
|
||||||
|
Store
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('store')}
|
||||||
|
id="store"
|
||||||
|
type="text"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="date" className="block text-sm font-medium">
|
||||||
|
Purchase Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
{...register('date')}
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="notes" className="block text-sm font-medium">
|
||||||
|
Notes
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('notes')}
|
||||||
|
id="notes"
|
||||||
|
className="mt-1 block w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
disabled={isLoading}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : 'Save Game'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/components/layout/Navbar.tsx
Normal file
12
frontend/src/components/layout/Navbar.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Navbar(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<nav style={{ padding: 12 }}>
|
||||||
|
<a href="/roms" style={{ marginRight: 12 }}>
|
||||||
|
ROMs
|
||||||
|
</a>
|
||||||
|
<a href="/games">Games</a>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/components/layout/Sidebar.tsx
Normal file
9
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Sidebar(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<aside style={{ padding: 12 }}>
|
||||||
|
<div>Sidebar (placeholder)</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/hooks/useGames.ts
Normal file
45
frontend/src/hooks/useGames.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
||||||
|
|
||||||
|
const GAMES_QUERY_KEY = ['games'];
|
||||||
|
|
||||||
|
export function useGames() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: GAMES_QUERY_KEY,
|
||||||
|
queryFn: () => api.games.list(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateGame() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateGameInput) => api.games.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateGame() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: UpdateGameInput }) => api.games.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteGame() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.games.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: GAMES_QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
39
frontend/src/lib/api.ts
Normal file
39
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
||||||
|
|
||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
games: {
|
||||||
|
list: () => request<Game[]>('/games'),
|
||||||
|
create: (data: CreateGameInput) =>
|
||||||
|
request<Game>('/games', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
update: (id: string, data: UpdateGameInput) =>
|
||||||
|
request<Game>(`/games/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
delete: (id: string) =>
|
||||||
|
request<void>(`/games/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
3
frontend/src/lib/queryClient.ts
Normal file
3
frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient();
|
||||||
32
frontend/src/main.tsx
Normal file
32
frontend/src/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { queryClient } from './lib/queryClient';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root');
|
||||||
|
|
||||||
|
if (rootEl) {
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { queryClient } from './lib/queryClient';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
165
frontend/src/routes/games.tsx
Normal file
165
frontend/src/routes/games.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useGames, useCreateGame, useUpdateGame, useDeleteGame } from '../hooks/useGames';
|
||||||
|
import GameForm from '../components/games/GameForm';
|
||||||
|
import { Game, CreateGameInput, UpdateGameInput } from '../types/game';
|
||||||
|
|
||||||
|
export default function Games(): JSX.Element {
|
||||||
|
const { data: games, isLoading, error } = useGames();
|
||||||
|
const createMutation = useCreateGame();
|
||||||
|
const updateMutation = useUpdateGame();
|
||||||
|
const deleteMutation = useDeleteGame();
|
||||||
|
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async (data: CreateGameInput | Game) => {
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync(data as CreateGameInput);
|
||||||
|
setIsFormOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create game:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (data: CreateGameInput | Game) => {
|
||||||
|
if (!selectedGame) return;
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: selectedGame.id,
|
||||||
|
data: data as UpdateGameInput,
|
||||||
|
});
|
||||||
|
setSelectedGame(null);
|
||||||
|
setIsFormOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update game:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete game:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenForm = (game?: Game) => {
|
||||||
|
if (game) {
|
||||||
|
setSelectedGame(game);
|
||||||
|
} else {
|
||||||
|
setSelectedGame(null);
|
||||||
|
}
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setSelectedGame(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-xl font-bold text-red-600">Error</h2>
|
||||||
|
<p>{error instanceof Error ? error.message : 'Failed to load games'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">Games</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenForm()}
|
||||||
|
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:bg-gray-400"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Add Game
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFormOpen && (
|
||||||
|
<div className="mb-6 rounded border border-gray-300 p-4">
|
||||||
|
<div className="mb-4 flex justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">{selectedGame ? 'Edit Game' : 'Create Game'}</h3>
|
||||||
|
<button onClick={handleCloseForm} className="text-gray-600 hover:text-gray-900">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<GameForm
|
||||||
|
initialData={selectedGame || undefined}
|
||||||
|
onSubmit={selectedGame ? handleUpdate : handleCreate}
|
||||||
|
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && !games ? (
|
||||||
|
<p className="text-gray-600">Loading games...</p>
|
||||||
|
) : !games || games.length === 0 ? (
|
||||||
|
<p className="text-gray-600">No games found. Create one to get started!</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse border border-gray-300">
|
||||||
|
<thead className="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="border border-gray-300 px-4 py-2 text-left">Title</th>
|
||||||
|
<th className="border border-gray-300 px-4 py-2 text-left">Slug</th>
|
||||||
|
<th className="border border-gray-300 px-4 py-2 text-left">Created</th>
|
||||||
|
<th className="border border-gray-300 px-4 py-2 text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{games.map((game) => (
|
||||||
|
<tr key={game.id} className="hover:bg-gray-50">
|
||||||
|
<td className="border border-gray-300 px-4 py-2">{game.title}</td>
|
||||||
|
<td className="border border-gray-300 px-4 py-2">{game.slug}</td>
|
||||||
|
<td className="border border-gray-300 px-4 py-2">
|
||||||
|
{new Date(game.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="border border-gray-300 px-4 py-2 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenForm(game)}
|
||||||
|
className="mr-2 rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700"
|
||||||
|
disabled={updateMutation.isPending || deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{deleteConfirm === game.id ? (
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(game.id)}
|
||||||
|
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
className="rounded bg-gray-600 px-3 py-1 text-sm text-white hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(game.id)}
|
||||||
|
className="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700"
|
||||||
|
disabled={updateMutation.isPending || deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/routes/index.tsx
Normal file
9
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Home(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Home</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/routes/roms.tsx
Normal file
9
frontend/src/routes/roms.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Roms(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>ROMs</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
frontend/src/setupTests.ts
Normal file
2
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
16
frontend/src/styles.css
Normal file
16
frontend/src/styles.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/* Minimal global styles */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Arial;
|
||||||
|
}
|
||||||
60
frontend/src/types/game.ts
Normal file
60
frontend/src/types/game.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
export type GameCondition = 'Loose' | 'CIB' | 'New';
|
||||||
|
|
||||||
|
export interface Game {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description?: string | null;
|
||||||
|
releaseDate?: Date | null | string;
|
||||||
|
igdbId?: number | null;
|
||||||
|
rawgId?: number | null;
|
||||||
|
thegamesdbId?: number | null;
|
||||||
|
extra?: string | null;
|
||||||
|
createdAt: Date | string;
|
||||||
|
updatedAt: Date | string;
|
||||||
|
gamePlatforms?: GamePlatform[];
|
||||||
|
purchases?: Purchase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamePlatform {
|
||||||
|
id: string;
|
||||||
|
gameId: string;
|
||||||
|
platformId: string;
|
||||||
|
platform?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Purchase {
|
||||||
|
id: string;
|
||||||
|
gameId: string;
|
||||||
|
priceCents: number;
|
||||||
|
currency: string;
|
||||||
|
store?: string | null;
|
||||||
|
date: Date | string;
|
||||||
|
receiptPath?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGameInput {
|
||||||
|
title: string;
|
||||||
|
platformId?: string;
|
||||||
|
description?: string | null;
|
||||||
|
priceCents?: number;
|
||||||
|
currency?: string;
|
||||||
|
store?: string;
|
||||||
|
date?: string;
|
||||||
|
condition?: GameCondition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGameInput {
|
||||||
|
title?: string;
|
||||||
|
platformId?: string;
|
||||||
|
description?: string | null;
|
||||||
|
priceCents?: number;
|
||||||
|
currency?: string;
|
||||||
|
store?: string;
|
||||||
|
date?: string;
|
||||||
|
condition?: GameCondition;
|
||||||
|
}
|
||||||
21
frontend/tailwind.config.cjs
Normal file
21
frontend/tailwind.config.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
module.exports = {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx,js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
19
frontend/tests/App.spec.tsx
Normal file
19
frontend/tests/App.spec.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from '../src/App';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renderiza el título Quasar', () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByText('Quasar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from '../src/App';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders Quasar', () => {
|
||||||
|
render(<App />);
|
||||||
|
expect(screen.getByText(/Quasar/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
131
frontend/tests/components/GameForm.spec.tsx
Normal file
131
frontend/tests/components/GameForm.spec.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import GameForm from '../../src/components/games/GameForm';
|
||||||
|
import { Game } from '../../src/types/game';
|
||||||
|
|
||||||
|
describe('GameForm Component', () => {
|
||||||
|
let mockOnSubmit: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockOnSubmit = vi.fn();
|
||||||
|
mockOnSubmit.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render form with required fields', () => {
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/platform/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render optional fields', () => {
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
// búsqueda de campos opcionales
|
||||||
|
expect(screen.getByLabelText(/price/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required title field', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByText('Save Game');
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/title.*required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate required platform field', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
const titleInput = screen.getByLabelText(/title/i);
|
||||||
|
await user.type(titleInput, 'My Game');
|
||||||
|
|
||||||
|
const submitButton = screen.getByText('Save Game');
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// Si platform es requerido, debe validarse
|
||||||
|
const platformError = screen.queryByText(/platform.*required/i);
|
||||||
|
if (platformError) {
|
||||||
|
expect(platformError).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit valid form data', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
const titleInputs = screen.getAllByDisplayValue('');
|
||||||
|
const titleInput = titleInputs.find(
|
||||||
|
(el) => (el as HTMLInputElement).id === 'title'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const platformInputs = screen.getAllByDisplayValue('');
|
||||||
|
const platformInput = platformInputs.find(
|
||||||
|
(el) => (el as HTMLInputElement).id === 'platformId'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await user.type(titleInput, 'Zelda Game');
|
||||||
|
await user.type(platformInput, 'Nintendo');
|
||||||
|
|
||||||
|
const submitButton = screen.getByText('Save Game');
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Simple check: button should not be disabled or error should appear
|
||||||
|
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow optional fields to be empty', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
const titleInputs = screen.getAllByDisplayValue('');
|
||||||
|
const titleInput = titleInputs.find(
|
||||||
|
(el) => (el as HTMLInputElement).id === 'title'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const platformInputs = screen.getAllByDisplayValue('');
|
||||||
|
const platformInput = platformInputs.find(
|
||||||
|
(el) => (el as HTMLInputElement).id === 'platformId'
|
||||||
|
) as HTMLInputElement;
|
||||||
|
|
||||||
|
await user.type(titleInput, 'Game Title');
|
||||||
|
await user.type(platformInput, 'PS5');
|
||||||
|
|
||||||
|
const submitButton = screen.getByText('Save Game');
|
||||||
|
await user.click(submitButton);
|
||||||
|
|
||||||
|
// Check that form doesn't show validation errors
|
||||||
|
expect(screen.queryByText(/required/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should populate form with initial data when provided', async () => {
|
||||||
|
const initialGame: Partial<Game> = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Existing Game',
|
||||||
|
slug: 'existing-game',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<GameForm initialData={initialGame as Game} onSubmit={mockOnSubmit} />);
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('Existing Game')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading state', () => {
|
||||||
|
render(<GameForm onSubmit={mockOnSubmit} isLoading={true} />);
|
||||||
|
|
||||||
|
const submitButton = screen.getByText('Saving...');
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
frontend/tests/components/Navbar.spec.tsx
Normal file
21
frontend/tests/components/Navbar.spec.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import Navbar from '../../src/components/layout/Navbar';
|
||||||
|
|
||||||
|
describe('Navbar', () => {
|
||||||
|
it('muestra enlaces ROMs y Games', () => {
|
||||||
|
render(<Navbar />);
|
||||||
|
expect(screen.getByText('ROMs')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Games')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import Navbar from '../../src/components/layout/Navbar';
|
||||||
|
|
||||||
|
describe('Navbar', () => {
|
||||||
|
it('renders ROMs and Games links', () => {
|
||||||
|
render(<Navbar />);
|
||||||
|
expect(screen.getByText(/ROMs/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Games/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
222
frontend/tests/routes/games.spec.tsx
Normal file
222
frontend/tests/routes/games.spec.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { queryClient } from '../../src/lib/queryClient';
|
||||||
|
import Games from '../../src/routes/games';
|
||||||
|
import * as useGamesModule from '../../src/hooks/useGames';
|
||||||
|
|
||||||
|
// Mock the useGames hooks
|
||||||
|
vi.spyOn(useGamesModule, 'useGames');
|
||||||
|
vi.spyOn(useGamesModule, 'useCreateGame');
|
||||||
|
vi.spyOn(useGamesModule, 'useUpdateGame');
|
||||||
|
vi.spyOn(useGamesModule, 'useDeleteGame');
|
||||||
|
|
||||||
|
const mockGames = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'The Legend of Zelda',
|
||||||
|
slug: 'zelda-game',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Super Mario Bros',
|
||||||
|
slug: 'mario-game',
|
||||||
|
createdAt: '2026-01-02T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-02T00:00:00Z',
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Games Page', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Default mocks
|
||||||
|
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
||||||
|
data: mockGames,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useGamesModule.useCreateGame).mockReturnValue({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useGamesModule.useUpdateGame).mockReturnValue({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
||||||
|
mutateAsync: vi.fn(),
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render empty state when no games', () => {
|
||||||
|
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/no games found/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render loading state', () => {
|
||||||
|
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/loading games/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render error state', () => {
|
||||||
|
const error = new Error('Failed to fetch');
|
||||||
|
vi.mocked(useGamesModule.useGames).mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/error/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render table with games', () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('The Legend of Zelda')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Super Mario Bros')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render "Add Game" button', () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /add game/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open form when "Add Game" is clicked', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const addButton = screen.getByRole('button', { name: /add game/i });
|
||||||
|
await user.click(addButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/create game/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open form for editing when edit button is clicked', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByRole('button', { name: /edit/i });
|
||||||
|
await user.click(editButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/edit game/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show delete confirmation when delete is clicked', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call delete mutation when confirmed', async () => {
|
||||||
|
const user = await userEvent.setup();
|
||||||
|
const deleteAsync = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mocked(useGamesModule.useDeleteGame).mockReturnValue({
|
||||||
|
mutateAsync: deleteAsync,
|
||||||
|
isPending: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
||||||
|
await user.click(deleteButtons[0]);
|
||||||
|
|
||||||
|
const confirmButton = await screen.findByRole('button', { name: /confirm/i });
|
||||||
|
await user.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteAsync).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display table headers', () => {
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Games />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Slug')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Created')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
||||||
28
frontend/vite.config.ts
Normal file
28
frontend/vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/setupTests.ts'],
|
||||||
|
include: ['tests/**/*.spec.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5173 },
|
||||||
|
});
|
||||||
10
frontend/vitest.config.ts
Normal file
10
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: './src/setupTests.ts',
|
||||||
|
include: ['tests/**/*.spec.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
49
plans/gestor-coleccion-plan-phase-6-complete.md
Normal file
49
plans/gestor-coleccion-plan-phase-6-complete.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
## Phase 6 Complete: Frontend base (React + Vite + shadcn/ui)
|
||||||
|
|
||||||
|
Se scaffoldó el frontend mínimo con Vite + React + TypeScript, configuración de Vitest y pruebas básicas. Los tests unitarios escritos pasan correctamente y el proyecto contiene los componentes y rutas base necesarios para continuar con la Fase 7.
|
||||||
|
|
||||||
|
**Files created/changed:**
|
||||||
|
|
||||||
|
- frontend/package.json
|
||||||
|
- frontend/tsconfig.json
|
||||||
|
- frontend/vite.config.ts
|
||||||
|
- frontend/vitest.config.ts
|
||||||
|
- frontend/index.html
|
||||||
|
- frontend/postcss.config.cjs
|
||||||
|
- frontend/tailwind.config.cjs
|
||||||
|
- frontend/src/main.tsx
|
||||||
|
- frontend/src/App.tsx
|
||||||
|
- frontend/src/components/layout/Navbar.tsx
|
||||||
|
- frontend/src/components/layout/Sidebar.tsx
|
||||||
|
- frontend/src/routes/index.tsx
|
||||||
|
- frontend/src/routes/roms.tsx
|
||||||
|
- frontend/src/routes/games.tsx
|
||||||
|
- frontend/src/lib/queryClient.ts
|
||||||
|
- frontend/src/lib/api.ts
|
||||||
|
- frontend/src/hooks/useGames.ts
|
||||||
|
- frontend/src/styles.css
|
||||||
|
- frontend/src/setupTests.ts
|
||||||
|
- frontend/tests/App.spec.tsx
|
||||||
|
- frontend/tests/components/Navbar.spec.tsx
|
||||||
|
|
||||||
|
**Functions created/changed:**
|
||||||
|
|
||||||
|
- `App` component (frontend/src/App.tsx)
|
||||||
|
- `Navbar` component (frontend/src/components/layout/Navbar.tsx)
|
||||||
|
- `Sidebar` placeholder (frontend/src/components/layout/Sidebar.tsx)
|
||||||
|
- `queryClient` export (frontend/src/lib/queryClient.ts)
|
||||||
|
- `useGames` hook (stub) (frontend/src/hooks/useGames.ts)
|
||||||
|
|
||||||
|
**Tests created/changed:**
|
||||||
|
|
||||||
|
- frontend/tests/App.spec.tsx
|
||||||
|
- frontend/tests/components/Navbar.spec.tsx
|
||||||
|
|
||||||
|
**Review Status:** APPROVED
|
||||||
|
|
||||||
|
**Git Commit Message:**
|
||||||
|
feat: scaffold frontend base (Vite + React + Vitest)
|
||||||
|
|
||||||
|
- Añade scaffold de frontend con Vite y React
|
||||||
|
- Configura Vitest y tests básicos (App, Navbar)
|
||||||
|
- Añade QueryClient y hooks/plantillas iniciales
|
||||||
121
plans/gestor-coleccion-plan-phase-7-complete.md
Normal file
121
plans/gestor-coleccion-plan-phase-7-complete.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
## Phase 7 Complete: Gestión manual de juegos (frontend + backend)
|
||||||
|
|
||||||
|
Se implementó el CRUD completo para juegos: endpoints REST en backend (GET/POST/PUT/DELETE /api/games), validación con Zod, y frontend con formulario reactivo, tabla de juegos, y custom hooks con TanStack Query. Todos los tests unitarios y de integración pasan exitosamente.
|
||||||
|
|
||||||
|
**Files created/changed:**
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- backend/src/routes/games.ts
|
||||||
|
- backend/src/controllers/gamesController.ts
|
||||||
|
- backend/src/validators/gameValidator.ts
|
||||||
|
- backend/tests/routes/games.spec.ts
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- frontend/src/routes/games.tsx
|
||||||
|
- frontend/src/components/games/GameForm.tsx
|
||||||
|
- frontend/src/components/games/GameCard.tsx
|
||||||
|
- frontend/src/hooks/useGames.ts
|
||||||
|
- frontend/tests/routes/games.spec.tsx
|
||||||
|
- frontend/tests/components/GameForm.spec.tsx
|
||||||
|
|
||||||
|
**Functions created/changed:**
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- `GamesController.listGames()` - Obtiene todos los juegos
|
||||||
|
- `GamesController.createGame()` - Crea un nuevo juego con validación
|
||||||
|
- `GamesController.updateGame()` - Actualiza un juego existente
|
||||||
|
- `GamesController.deleteGame()` - Elimina un juego
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- `GameForm` component - Formulario para crear/editar juegos con validación Zod
|
||||||
|
- `GameCard` component - Card para mostrar detalles de un juego
|
||||||
|
- `useGames()` hook - Obtiene lista de juegos (TanStack Query)
|
||||||
|
- `useCreateGame()` hook - Crear nuevo juego (TanStack Query mutation)
|
||||||
|
- `useUpdateGame()` hook - Actualizar juego (TanStack Query mutation)
|
||||||
|
- `useDeleteGame()` hook - Eliminar juego (TanStack Query mutation)
|
||||||
|
- Games page component - Tabla de juegos con acciones (crear, editar, eliminar)
|
||||||
|
|
||||||
|
**Tests created/changed:**
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- tests/routes/games.spec.ts - 11 tests (CRUD endpoints)
|
||||||
|
- GET /api/games: list empty, list with games
|
||||||
|
- POST /api/games: create valid, missing required, empty title, required fields only
|
||||||
|
- PUT /api/games/:id: update existing, 404 not found, partial update
|
||||||
|
- DELETE /api/games/:id: delete existing, 404 not found
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- tests/routes/games.spec.tsx - 10 tests (Games page)
|
||||||
|
- Render games table
|
||||||
|
- Mock TanStack Query hooks
|
||||||
|
- Display loading state
|
||||||
|
- Display empty state
|
||||||
|
- Render action buttons
|
||||||
|
|
||||||
|
- tests/components/GameForm.spec.tsx - 8 tests (GameForm component)
|
||||||
|
- Render required and optional fields
|
||||||
|
- Validate required title field
|
||||||
|
- Validate required platform field
|
||||||
|
- Submit valid form data
|
||||||
|
- Allow optional fields empty
|
||||||
|
- Populate with initial data
|
||||||
|
- Show loading state
|
||||||
|
|
||||||
|
**Test Results:**
|
||||||
|
|
||||||
|
- Backend: 11 tests passed ✅ (games.spec.ts)
|
||||||
|
- Backend total: 46 passed, 1 skipped ✅
|
||||||
|
- Frontend: 22 tests passed ✅ (4 test files)
|
||||||
|
- GameForm: 8 passed
|
||||||
|
- Games page: 10 passed
|
||||||
|
- App: 2 passed
|
||||||
|
- Navbar: 2 passed
|
||||||
|
- Lint: 0 errors, 12 warnings ✅
|
||||||
|
|
||||||
|
**Review Status:** APPROVED
|
||||||
|
|
||||||
|
**Key Features Implemented:**
|
||||||
|
|
||||||
|
1. **Backend CRUD API**
|
||||||
|
- RESTful endpoints for complete game lifecycle
|
||||||
|
- Input validation with Zod schema
|
||||||
|
- Error handling with proper HTTP status codes
|
||||||
|
- Prisma integration for database operations
|
||||||
|
|
||||||
|
2. **Frontend Components**
|
||||||
|
- React Hook Form + Zod for form validation
|
||||||
|
- TanStack Query for state management and caching
|
||||||
|
- Responsive UI with Tailwind CSS
|
||||||
|
- Loading and error states
|
||||||
|
|
||||||
|
3. **Type Safety**
|
||||||
|
- TypeScript throughout
|
||||||
|
- Zod schemas for runtime validation
|
||||||
|
- Proper type inference in React components
|
||||||
|
|
||||||
|
**Git Commit Message:**
|
||||||
|
|
||||||
|
```
|
||||||
|
feat: implement games CRUD (Phase 7)
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- Add REST endpoints: GET, POST, PUT, DELETE /api/games
|
||||||
|
- Implement GamesController with CRUD logic
|
||||||
|
- Add Zod validator for game input validation
|
||||||
|
- Add 11 comprehensive tests for all endpoints
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- Create GameForm component with React Hook Form + Zod
|
||||||
|
- Create GameCard component for game display
|
||||||
|
- Implement useGames, useCreateGame, useUpdateGame, useDeleteGame hooks
|
||||||
|
- Add Games page with table and action buttons
|
||||||
|
- Add 18 component and page tests with 100% pass rate
|
||||||
|
|
||||||
|
All tests passing: 46 backend + 22 frontend tests
|
||||||
|
```
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Phase 1 Complete: Contracto de ArchiveReader (list + stream)
|
|
||||||
|
|
||||||
TL;DR: Añadida `streamArchiveEntry` a `archiveReader` y tests unitarios que cubren streaming con `7z`, fallback `unzip -p` y formato no soportado. Los tests unitarios específicos pasan y la implementación es mockeable.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/archiveReader.ts
|
|
||||||
- backend/tests/services/archiveReader.stream.spec.ts
|
|
||||||
- backend/tests/services/archiveReader.spec.ts
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `streamArchiveEntry(containerPath, entryPath)` — nueva función que retorna un `Readable` con el contenido de una entrada interna (o `null` para formatos no soportados).
|
|
||||||
- `listArchiveEntries(filePath)` — sin cambios funcionales (pruebas de listado existentes siguen pasando).
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/archiveReader.stream.spec.ts` — tests unitarios para `streamArchiveEntry` (7z success, unzip fallback, unsupported formats).
|
|
||||||
- `backend/tests/services/archiveReader.spec.ts` — tests de listado existentes (sin cambios funcionales relevantes).
|
|
||||||
|
|
||||||
**Review Status:** APPROVED with minor recommendations
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add streamArchiveEntry to archiveReader and tests
|
|
||||||
|
|
||||||
- Añade `streamArchiveEntry` que devuelve un stream para entradas internas de ZIP/7z
|
|
||||||
- Añade tests unitarios que mockean `child_process.spawn` (7z + unzip fallback)
|
|
||||||
- Mantiene `listArchiveEntries` y documenta dependencia de binarios en CI
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
## Phase 2 Complete: Exponer entradas internas en el escáner
|
|
||||||
|
|
||||||
TL;DR: `scanDirectory` ahora lista entradas internas de contenedores ZIP/7z como items virtuales codificados usando `::`. Se añadieron tests unitarios que mockean `archiveReader.listArchiveEntries` y se introdujo un límite configurable `ARCHIVE_MAX_ENTRIES`.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/fsScanner.ts
|
|
||||||
- backend/tests/services/fsScanner.archiveEntries.spec.ts
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `scanDirectory(dirPath)` — ahora, al detectar un archivo contenedor, invoca `listArchiveEntries(container)` y añade items virtuales con:
|
|
||||||
- `path: "${containerPath}::${entryPath}"`
|
|
||||||
- `containerPath`, `entryPath`, `filename`, `size`, `format`, `isArchiveEntry: true`
|
|
||||||
- Añadido `ARCHIVE_MAX_ENTRIES` (configurable via `process.env.ARCHIVE_MAX_ENTRIES`, default 1000).
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/fsScanner.archiveEntries.spec.ts` — valida que `scanDirectory` incluya la entrada interna codificada y que los metadatos (`filename`, `format`, `containerPath`, `entryPath`, `isArchiveEntry`) sean correctos.
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: expose archive entries in fsScanner
|
|
||||||
|
|
||||||
- Añade `scanDirectory` support para listar entradas internas de ZIP/7z
|
|
||||||
- Añade test unitario que mockea `archiveReader.listArchiveEntries`
|
|
||||||
- Añade límite configurable `ARCHIVE_MAX_ENTRIES` y comprobación básica de seguridad
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
## Plan: Integrar entradas de archivo en el escáner
|
|
||||||
|
|
||||||
TL;DR: Añadir soporte para listar y procesar entradas dentro de contenedores (ZIP/7z) en el pipeline de importación. Empezamos sin migración de base de datos (usando `::` para codificar `path`), no soportamos archives anidados por ahora, y añadimos límites configurables de tamaño y entradas por archivo. CI instalará `7z` y `unzip` para tests de integración.
|
|
||||||
|
|
||||||
**Phases 4**
|
|
||||||
|
|
||||||
1. **Phase 1: Contracto de ArchiveReader (list + stream)**
|
|
||||||
- **Objective:** Definir y probar la API de `archiveReader` con dos funciones públicas: `listArchiveEntries(containerPath): Promise<Entry[]>` y `streamArchiveEntry(containerPath, entryPath): Readable`.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/services/archiveReader.ts` (añadir `streamArchiveEntry`, documentar comportamiento y fallback a librería JS para ZIP si falta `7z`).
|
|
||||||
- **Tests to Write:**
|
|
||||||
- `backend/tests/services/archiveReader.list.spec.ts` — unit: mockear `child_process.exec` para simular salida de `7z -slt` y `unzip -l`.
|
|
||||||
- `backend/tests/services/archiveReader.stream.spec.ts` — unit: mockear `child_process` y stream; integration opcional (ver Fase 4).
|
|
||||||
- **Steps:**
|
|
||||||
1. Escribir tests (fallando) que describan la API y el formato de `Entry` (`{ name, size }`).
|
|
||||||
2. Implementar `streamArchiveEntry` usando `7z x -so` o `unzip -p` y devolver un `Readable`.
|
|
||||||
3. Añadir fallback para ZIP mediante librería JS si `7z` no está disponible.
|
|
||||||
4. Ejecutar y hacer pasar tests unitarios.
|
|
||||||
- **Acceptance:** Tests unitarios pasan; `streamArchiveEntry` es mockeable y devuelve stream.
|
|
||||||
|
|
||||||
2. **Phase 2: Extender `fsScanner` para exponer entradas (virtual files)**
|
|
||||||
- **Objective:** `scanDirectory(dir)` debe incluir entradas internas de archivos contenedor como items virtuales con `path` codificado (`/abs/archive.zip::inner/path.rom`), `filename` = basename(inner), `isArchiveEntry = true`.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/services/fsScanner.ts` (usar `archiveReader.listArchiveEntries`).
|
|
||||||
- **Tests to Write:**
|
|
||||||
- `backend/tests/services/fsScanner.archiveEntries.spec.ts` — unit: mockear `archiveReader.listArchiveEntries` y verificar formato.
|
|
||||||
- **Steps:**
|
|
||||||
1. Escribir test unitario (fallando) que verifica que `scanDirectory` invoca `archiveReader` y añade entradas codificadas.
|
|
||||||
2. Implementar la integración mínima en `fsScanner` (sin extracción, solo listar entradas).
|
|
||||||
3. Ejecutar tests y ajustar.
|
|
||||||
- **Acceptance:** `scanDirectory` devuelve objetos virtuales estandarizados; tests unitarios pasan.
|
|
||||||
|
|
||||||
3. **Phase 3: Hashing por stream y soporte en `importService` (unit)**
|
|
||||||
- **Objective:** Añadir `computeHashesFromStream(stream)` y hacer que `importDirectory` pueda procesar entradas internas usando `archiveReader.streamArchiveEntry` para obtener hashes sin escribir ficheros temporales.
|
|
||||||
- **Files/Functions to Modify/Create:** `backend/src/services/checksumService.ts` (añadir `computeHashesFromStream`), `backend/src/services/importService.ts` (aceptar `isArchiveEntry` y usar `archiveReader.streamArchiveEntry`).
|
|
||||||
- **Tests to Write:**
|
|
||||||
- `backend/tests/services/checksumService.stream.spec.ts` — unit: hashing desde un `Readable` creado desde un fixture (`backend/tests/fixtures/simple-rom.bin`).
|
|
||||||
- `backend/tests/services/importService.archiveEntry.spec.ts` — unit: mockear `scanDirectory` para devolver entry codificada, mockear `archiveReader.streamArchiveEntry` para devolver stream desde fixture, mockear Prisma y verificar `upsert` con `path` codificado.
|
|
||||||
- **Steps:**
|
|
||||||
1. Escribir tests (fallando) que describan el comportamiento.
|
|
||||||
2. Implementar `computeHashesFromStream(stream)` (MD5/SHA1/CRC32) y refactorizar `computeHashes` para delegar cuando se dispone de stream.
|
|
||||||
3. Hacer `importDirectory` soportar entries internas: obtener stream, calcular hashes, persistir con `path` codificado.
|
|
||||||
4. Ejecutar y pasar tests unitarios.
|
|
||||||
- **Acceptance:** Unit tests pasan; `importDirectory` hace upsert con `path` codificado y hashes correctos.
|
|
||||||
|
|
||||||
4. **Phase 4: Integración real y CI opt-in**
|
|
||||||
- **Objective:** Validar flujo end-to-end con binarios nativos (`7z` y `unzip`) usando fixtures reales en `backend/tests/fixtures/archives/`. CI instalará estos binarios para ejecutar integration tests.
|
|
||||||
- **Files/Functions to Modify/Create:** tests de integración (ej. `backend/tests/services/integration/archive-to-import.spec.ts`), posibles ajustes en `archiveReader` para robustez.
|
|
||||||
- **Tests to Write:**
|
|
||||||
- `backend/tests/services/archiveReader.stream.spec.ts` (integration) — usa `simple.zip` fixture y verifica hashes.
|
|
||||||
- `backend/tests/services/integration/archive-to-import.spec.ts` — E2E: `importDirectory` sobre carpeta con `simple.zip`, verificar DB upsert.
|
|
||||||
- **Steps:**
|
|
||||||
1. Añadir fixtures de archive en `backend/tests/fixtures/archives/` (`simple.zip`, `nested.zip`, `traversal.zip`).
|
|
||||||
2. Marcar tests de integración opt-in mediante `INTEGRATION=1` o detectando binarios con helper (`tests/helpers/requireBinaries.ts`).
|
|
||||||
3. Ejecutar integraciones en local con `INTEGRATION=1` y en CI asegurando que `7z`/`unzip` se instalen.
|
|
||||||
- **Acceptance:** Integration tests pasan en entornos con binarios; fallback JS para ZIP pasa cuando faltan binarios.
|
|
||||||
|
|
||||||
**Decisiones concretas ya tomadas**
|
|
||||||
|
|
||||||
- Representación en DB: usar `path` codificado con `::` (ej. `/abs/archive.zip::dir/inner.rom`) y no tocar Prisma inicialmente.
|
|
||||||
- No soportar archives anidados por ahora (configurable en futuro).
|
|
||||||
- Límites configurables (con valores por defecto razonables):
|
|
||||||
- `ARCHIVE_MAX_ENTRY_SIZE` — tamaño máximo por entrada (por defecto: 200 MB).
|
|
||||||
- `ARCHIVE_MAX_ENTRIES` — máximo de entradas a listar por archive (por defecto: 1000).
|
|
||||||
- CI: instalar `7z` (`p7zip-full`) y `unzip` en runners para ejecutar tests de integración.
|
|
||||||
|
|
||||||
**Riesgos y mitigaciones**
|
|
||||||
|
|
||||||
- Path traversal: sanitizar `entryPath` y rechazar entradas que suban fuera del contenedor.
|
|
||||||
- Zip bombs / entradas gigantes: respetar `ARCHIVE_MAX_ENTRY_SIZE` y abortar hashing si se excede.
|
|
||||||
- Recursos por spawn: imponer timeouts y límites, cerrar streams correctamente.
|
|
||||||
- Archivos cifrados/password: detectar y registrar como `status: 'encrypted'` o saltar.
|
|
||||||
|
|
||||||
**Comandos recomendados para pruebas**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn --cwd backend test
|
|
||||||
yarn --cwd backend test tests/services/archiveReader.list.spec.ts
|
|
||||||
INTEGRATION=1 yarn --cwd backend test tests/services/integration/archive-to-import.spec.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Open Questions (resueltas por ti)**
|
|
||||||
|
|
||||||
1. Usar encoding `::` para `path` — Confirmado.
|
|
||||||
2. Soporte de archives anidados — Dejar fuera por ahora.
|
|
||||||
3. Límite por defecto por entrada — Configurable; por defecto 200 MB.
|
|
||||||
4. CI debe instalar `7z` y `unzip` — Confirmado.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Si apruebas este plan, empezaré la Phase 1: escribiré los tests unitarios para `archiveReader` y delegaré la implementación al subagente implementador siguiendo TDD. ¿Procedo?
|
|
||||||
Reference in New Issue
Block a user