Compare commits
13 Commits
97a7f74685
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b92cc19137 | |||
| 9ed4437906 | |||
| 0c9c408564 | |||
| c27e9bec7a | |||
| ce54db38d9 | |||
| 907d3042bc | |||
| 9befb8db6c | |||
| 2609d156cb | |||
| 571ac97f00 | |||
| 630ebe0dc8 | |||
| 08aca0fd5b | |||
| 79c42fad55 | |||
| 7ca465fb73 |
@@ -1,85 +0,0 @@
|
|||||||
# CI pipeline for Quasar (Gitea Actions)
|
|
||||||
# Jobs: build_and_test (install, prisma generate, lint, unit tests)
|
|
||||||
# e2e (Playwright, runs on pushes to main)
|
|
||||||
# Note: `prisma generate` is allowed to continue on error to avoid blocking CI when native engines can't be built.
|
|
||||||
|
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
pull_request:
|
|
||||||
branches: [ main ]
|
|
||||||
|
|
||||||
env:
|
|
||||||
DATABASE_URL: file:./backend/dev.db
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build_and_test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --immutable
|
|
||||||
|
|
||||||
- name: Install native archive tools (p7zip, chdman)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
# 7z / p7zip
|
|
||||||
sudo apt-get install -y p7zip-full p7zip-rar || true
|
|
||||||
# chdman (intentar instalar desde paquetes disponibles: mame-tools o mame)
|
|
||||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Prisma: generate (may fail on some runners)
|
|
||||||
run: yarn workspace quasar-backend run prisma:generate
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Lint (backend)
|
|
||||||
run: yarn workspace quasar-backend run lint
|
|
||||||
|
|
||||||
- name: Run backend unit tests
|
|
||||||
run: yarn workspace quasar-backend run test:ci
|
|
||||||
|
|
||||||
e2e:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build_and_test]
|
|
||||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'yarn'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install --immutable
|
|
||||||
|
|
||||||
- name: Install native archive tools (p7zip, chdman)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y p7zip-full p7zip-rar || true
|
|
||||||
sudo apt-get install -y mame-tools || sudo apt-get install -y mame || true
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: yarn test:install
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
- name: Run Playwright E2E
|
|
||||||
run: yarn test:ci
|
|
||||||
|
|
||||||
# Metadatos
|
|
||||||
# Autor: Quasar (investigación automatizada)
|
|
||||||
# Última actualización: 2026-02-08
|
|
||||||
98
.gitea/workflows/ci.yml
Normal file
98
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '18'
|
||||||
|
YARN_VERSION: '4.12.0'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Job 1: Lint (Lint backend + root esconfig)
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn lint
|
||||||
|
|
||||||
|
# Job 2: Backend Tests
|
||||||
|
test-backend:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: 'file:./test.db'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: cd backend && yarn test:ci
|
||||||
|
|
||||||
|
# Job 3: Frontend Tests
|
||||||
|
test-frontend:
|
||||||
|
needs: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: cd frontend && yarn test:run
|
||||||
|
|
||||||
|
# Job 4: E2E Tests (BLOQUEANTE - must pass)
|
||||||
|
test-e2e:
|
||||||
|
needs:
|
||||||
|
- test-backend
|
||||||
|
- test-frontend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: 'file:./test.db'
|
||||||
|
IGDB_CLIENT_ID: ${{ secrets.IGDB_CLIENT_ID }}
|
||||||
|
IGDB_CLIENT_SECRET: ${{ secrets.IGDB_CLIENT_SECRET }}
|
||||||
|
RAWG_API_KEY: ${{ secrets.RAWG_API_KEY }}
|
||||||
|
THEGAMESDB_API_KEY: ${{ secrets.THEGAMESDB_API_KEY }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: 'yarn'
|
||||||
|
- run: yarn install
|
||||||
|
- run: yarn test:install
|
||||||
|
|
||||||
|
# Start backend in background
|
||||||
|
- run: |
|
||||||
|
cd backend && yarn dev &
|
||||||
|
sleep 5
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Start frontend in background
|
||||||
|
- run: |
|
||||||
|
cd frontend && yarn dev &
|
||||||
|
sleep 5
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Run E2E tests (timeout 5 min)
|
||||||
|
- run: |
|
||||||
|
timeout 300 yarn test:e2e --reporter=list || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Upload Playwright report on failure
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
41
.github/agents/Conductor.agent.md
vendored
41
.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
|
||||||
|
|
||||||
@@ -83,17 +102,20 @@ For each phase in the plan, execute this cycle:
|
|||||||
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
|
||||||
@@ -101,12 +123,14 @@ When invoking subagents:
|
|||||||
</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,11 +142,13 @@ 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.
|
||||||
@@ -137,18 +163,21 @@ 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
|
||||||
@@ -159,6 +188,7 @@ File name: `<plan-name>-phase-<phase-number>-complete.md` (use kebab-case)
|
|||||||
**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}
|
||||||
|
|||||||
7
.github/agents/code-review-subagent.agent.md
vendored
7
.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:
|
||||||
@@ -30,6 +33,7 @@ CRITICAL: You receive context from the parent agent including:
|
|||||||
</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
|
||||||
|
|||||||
3
.github/agents/planning-subagent.agent.md
vendored
3
.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.
|
||||||
@@ -31,6 +32,7 @@ Your SOLE job is to gather comprehensive context about the requested task and re
|
|||||||
</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
|
||||||
@@ -40,6 +42,7 @@ Your SOLE job is to gather comprehensive context about the requested task and re
|
|||||||
</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': '*'
|
||||||
|
|||||||
406
README.md
406
README.md
@@ -1,61 +1,367 @@
|
|||||||
# Quasar
|
# Quasar 🎮
|
||||||
|
|
||||||
## Descripción
|
A self-hosted video game library manager. Scan ROM files, enrich metadata from multiple APIs (IGDB, RAWG, TheGamesDB), and manage your personal game collection.
|
||||||
|
|
||||||
Quasar es una aplicación web para al gestión de una biblioteca personal de videjuegos. Permite a los usuarios catalogar, organizar y buscar sus juegos de manera eficiente. Se pueden agregar videjuegos físicos, digitales y roms de emuladores.
|
## Features
|
||||||
|
|
||||||
## Características
|
- 📁 **ROM Scanning** — Scan directories for ROM files (ZIP, 7z, RAR)
|
||||||
|
- 🔍 **Metadata Enrichment** — Fetch game info, artwork, ratings from 3+ APIs
|
||||||
|
- 🎯 **Game Library** — Create, edit, and organize games by platform
|
||||||
|
- 🎨 **Multi-API Support** — IGDB (Twitch OAuth), RAWG, TheGamesDB
|
||||||
|
- 🎨 **Landing Page Inmersiva** — Mass Effect-inspired UI con glassmorphism y efectos holográficos
|
||||||
|
- ✅ **Web Interface Guidelines** — 95% compliance con accesibilidad y semántica HTML5
|
||||||
|
- 📱 **Mobile-First Responsive** — Diseño adaptable a todos los tamaños de pantalla
|
||||||
|
- <20>️ **Privacy First** — All data stored locally, no cloud sync
|
||||||
|
- 🔐 **Secure** — API keys via environment variables, never committed
|
||||||
|
|
||||||
- **Catálogo de Videjuegos**: Permite agregar, editar y eliminar juegos de la biblioteca.
|
## Quick Start
|
||||||
- **Organización**: Clasificación por género, plataforma, estado (completado, en progreso, pendiente) y calificación personal.
|
|
||||||
- **Búsqueda Avanzada**: Filtros para encontrar juegos rápidamente según diferentes criterios.
|
|
||||||
- **Búsqueda de Metadatos**: Integración con APIs externas para obtener información adicional sobre los juegos.
|
|
||||||
|
|
||||||
## Otros proyectos relacionados, para coger ideas y funcionalidades
|
### Prerequisites
|
||||||
|
|
||||||
| Herramienta | Categoría | Descripción | Features Destacadas | Ideal Para | Enlace Oficial |
|
- **Node.js 18+**
|
||||||
|-----------------------|-------------------------------|-------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
|
- **Yarn 4.x** (package manager)
|
||||||
| RomM | Gestor de ROMs y Metadatos | Gestor self-hosted de ROMs con interfaz web moderna. | Escanea, enriquece y navega por colecciones de juegos. Obtiene metadatos de IGDB, Screenscraper y MobyGames. Descarga automática de carátulas y fanarts. | Gestionar colecciones de ROMs y videojuegos retro/modernos con metadatos y assets visuales. | [GitHub - RomM](https://github.com/rommapp/romm) |
|
|
||||||
| Gaseous | Gestor de ROMs y Metadatos | Gestor de archivos ROM y metadatos con emulador basado en web. | Gestión de metadatos y archivos ROM. Emulador integrado accesible desde navegador. | Usuarios que buscan una solución todo-en-uno para ROMs y emulación web. | [GitHub - Gaseous](https://github.com/RetroESP32/gaseous) |
|
|
||||||
| RetroAssembly | Gestor de ROMs y Metadatos | Plataforma para mostrar colecciones de juegos retro en el navegador. | Interfaz web para visualizar y organizar juegos retro. | Coleccionistas de juegos retro que buscan una experiencia visual en el navegador. | [GitHub - RetroAssembly](https://github.com/RetroAssembly/RetroAssembly) |
|
|
||||||
| Gameyfin | Gestor de Bibliotecas | Gestor de bibliotecas de videojuegos similar a Jellyfin. | Escanea bibliotecas de juegos y presenta los títulos en un navegador web. Organiza juegos de Steam, Epic, GOG y otras fuentes. Interfaz limpia y soporte para plugins. | Gestionar y compartir juegos digitales con amigos o familia. | [GitHub - Gameyfin](https://github.com gameyfin/gameyfin) |
|
|
||||||
| GameVault | Gestor de Bibliotecas | Plataforma self-hosted para gestionar colecciones de videojuegos. | Integración con IGDB para metadatos y carátulas. Clasificación por edades y personalización de metadatos. Sistema de plugins. | Usuarios que buscan una solución robusta para metadatos y organización de juegos. | [GameVault](https://gamevau.lt/) |
|
|
||||||
| Retrom | Gestor de Bibliotecas | Servidor de distribución de bibliotecas de juegos retro + frontend/launcher. | Distribución y lanzamiento de juegos retro desde tu propio servidor. Interfaz personalizable. | Colecciones de juegos retro y distribución centralizada. | [GitHub - Retrom](https://github.com/RetroESP32/retrom) |
|
|
||||||
| Drop | Distribución de Juegos | Plataforma flexible de distribución de juegos. | Permite distribuir y gestionar juegos de forma centralizada. | Distribución de juegos en redes locales o comunidades. | [GitHub - Drop](https://github.com/drop-team/drop) |
|
|
||||||
| Pterodactyl | Gestión de Servidores | Panel de gestión de servidores de juegos open source. | Permite instalar y gestionar servidores de juegos como Minecraft, CS:GO, etc. Interfaz web moderna y soporte para múltiples usuarios. | Administrar servidores de juegos para comunidades o grupos. | [Pterodactyl](https://pterodactyl.io/) |
|
|
||||||
| LinuxGSM | Gestión de Servidores | Herramienta de línea de comandos para desplegar servidores de juegos dedicados. | Soporte para más de 100 servidores de juegos. Automatización de instalación y actualización. | Usuarios avanzados que prefieren la línea de comandos. | [LinuxGSM](https://linuxgsm.com/) |
|
|
||||||
| Lodestone | Gestión de Servidores | Herramienta de hosting open source para Minecraft y otros juegos multijugador. | Gestión simplificada de servidores de Minecraft y otros juegos. | Hosting de servidores de Minecraft y juegos similares. | [GitHub - Lodestone](https://github.com/Lodestone-Team/Lodestone) |
|
|
||||||
| auto-mcs | Gestión de Servidores | Gestor de servidores de Minecraft multiplataforma. | Automatización de la gestión de servidores de Minecraft. | Usuarios que buscan una solución sencilla para Minecraft. | [GitHub - auto-mcs](https://github.com/auto-mcs/auto-mcs) |
|
|
||||||
| Pelican Panel | Gestión de Servidores | Panel de control para servidores de juegos. | Interfaz web para gestionar servidores de juegos. | Alternativa a Pterodactyl, con enfoque en simplicidad. | [GitHub - Pelican Panel](https://github.com/pelican-panel/pelican) |
|
|
||||||
| Sunshine | Streaming y Acceso Remoto | Host de streaming de juegos para Moonlight. | Permite transmitir juegos desde tu PC a otros dispositivos. | Jugar en remoto desde tablets, móviles o TVs. | [GitHub - Sunshine](https://github.com/LizardByte/Sunshine) |
|
|
||||||
| Games on Whales | Streaming y Acceso Remoto | Plataforma para transmitir escritorios virtuales y juegos mediante Docker. | Streaming de juegos y escritorios virtuales. | Usuarios que buscan una solución de streaming basada en Docker. | [GitHub - Games on Whales](https://github.com/games-on-whales/gow) |
|
|
||||||
| PlanarAlly | Virtual Tabletop | Mesa de juego virtual con capacidades offline. | Soporte para juegos de rol y tablero. | Jugadores de rol que buscan una mesa virtual self-hosted. | [GitHub - PlanarAlly](https://github.com/PlanarAlly/planarally) |
|
|
||||||
| Foundry Virtual Tabletop | Virtual Tabletop | Plataforma moderna para juegos de rol. | Herramientas avanzadas para masters y jugadores. | Comunidades de juegos de rol que buscan una solución profesional. | [Foundry Virtual Tabletop](https://foundryvtt.com/) |
|
|
||||||
| LANCommander | Distribución de Juegos | Plataforma open source para distribución digital de videojuegos. | Permite distribuir juegos en una red local. | Distribución de juegos en LAN o comunidades pequeñas. | [GitHub - LANCommander](https://github.com/LANCommander/LANCommander) |
|
|
||||||
| Fireshare | Distribución de Juegos | Comparte clips de juegos, videos u otros medios mediante enlaces únicos. | Comparte contenido multimedia de forma sencilla. | Compartir capturas o videos de juegos con amigos. | [GitHub - Fireshare](https://github.com/fireshare/fireshare) |
|
|
||||||
| Crafty Controller | Herramientas para Minecraft | Panel de control y lanzador para servidores de Minecraft. | Gestión simplificada de servidores de Minecraft. | Administrar servidores de Minecraft de forma visual. | [GitHub - Crafty Controller](https://github.com/crafty-controller/crafty-controller) |
|
|
||||||
| Steam Headless | Herramientas para Steam | Servidor remoto de Steam sin cabeza (headless) mediante Docker. | Permite gestionar juegos de Steam en un servidor remoto. | Usuarios que quieren acceder a su biblioteca de Steam desde un servidor. | [GitHub - Steam Headless](https://github.com/steamheadless/steamheadless) |
|
|
||||||
|
|
||||||
## Dependencias nativas para tests de integración
|
### Installation
|
||||||
|
|
||||||
Algunos tests de integración (p. ej. verificación de DATs, lectura de CHD/7z)
|
```bash
|
||||||
requieren herramientas nativas instaladas en el sistema donde se ejecuten
|
# 1. Clone repository
|
||||||
los tests (local o CI). A continuación está la lista mínima y cómo instalarlas:
|
git clone https://your-gitea-instance/your-org/quasar.git
|
||||||
|
cd quasar
|
||||||
|
|
||||||
- `7z` / `p7zip` — necesario para extraer/leer ZIP y 7z.
|
# 2. Install dependencies
|
||||||
- Debian/Ubuntu: `sudo apt update && sudo apt install -y p7zip-full p7zip-rar`
|
yarn install
|
||||||
- macOS (Homebrew): `brew install p7zip`
|
|
||||||
|
|
||||||
- `chdman` — herramienta de MAME para manejar archivos CHD (opcional,
|
# 3. Setup environment
|
||||||
requerida para tests que trabajen con imágenes CHD).
|
cp .env.example .env.local
|
||||||
- Debian/Ubuntu: intentar `sudo apt install -y mame-tools` o `sudo apt install -y mame`.
|
|
||||||
- macOS (Homebrew): `brew install mame`
|
|
||||||
- Si no hay paquete disponible, descargar o compilar MAME/CHDTools desde
|
|
||||||
las fuentes oficiales.
|
|
||||||
|
|
||||||
Notas:
|
# 4. Get API keys (optional, but recommended)
|
||||||
- En CI se intentará instalar estas herramientas cuando sea posible; si no
|
# See: [docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)
|
||||||
están disponibles los tests de integración que dependan de ellas pueden
|
|
||||||
configurarse para ejecutarse condicionalmente.
|
# 5. Run migrations
|
||||||
- La variable de entorno `INTEGRATION=1` controla si se ejecutan pruebas
|
cd backend
|
||||||
más pesadas y dependientes de binarios.
|
npm run prisma:migrate
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 6. Start development servers
|
||||||
|
# Terminal 1: Backend
|
||||||
|
cd backend && yarn dev
|
||||||
|
|
||||||
|
# Terminal 2: Frontend (Next.js)
|
||||||
|
cd frontend && yarn dev
|
||||||
|
|
||||||
|
# 7. Open browser
|
||||||
|
# Frontend: http://localhost:3000
|
||||||
|
# Backend API: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Create Platform** — Go to /games, add Nintendo, PlayStation, etc.
|
||||||
|
2. **Create Game** — Add game manually or import from ROMs
|
||||||
|
3. **Scan ROMs** — Go to /roms, scan directory with ROM files
|
||||||
|
4. **Link Metadata** — Search game on IGDB/RAWG, link to ROM
|
||||||
|
5. **View Library** — See all games with artwork and info
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
quasar/
|
||||||
|
├── backend/ # Fastify API + Prisma + SQLite
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/ # REST endpoints (/games, /roms, /metadata, etc.)
|
||||||
|
│ │ ├── services/ # Business logic (metadata clients, romscanning, etc.)
|
||||||
|
│ │ └── controllers/ # Request handlers
|
||||||
|
│ └── tests/ # Vitest unit tests (63+ tests)
|
||||||
|
│
|
||||||
|
├── frontend/ # Next.js 16 + Shadcn UI + Tailwind CSS
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── layout.tsx # Root layout con metadata SEO
|
||||||
|
│ │ │ ├── page.tsx # Landing page con componentes
|
||||||
|
│ │ │ └── globals.css # Tema Mass Effect + animaciones
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── landing/
|
||||||
|
│ │ │ │ ├── Navbar.tsx # Navbar con glassmorphism
|
||||||
|
│ │ │ │ ├── Hero.tsx # Hero section con featured game
|
||||||
|
│ │ │ │ ├── GameGrid.tsx # Grid de tarjetas con hover effects
|
||||||
|
│ │ │ │ └── Footer.tsx # Footer minimalista
|
||||||
|
│ │ │ └── ui/ # Componentes Shadcn UI
|
||||||
|
│ │ └── lib/
|
||||||
|
│ │ └── utils.ts # Utilidades de Shadcn UI
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ ├── e2e/ # Playwright E2E tests (15 tests)
|
||||||
|
│ └── *.spec.ts # Config validation tests
|
||||||
|
│
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── README.md # Documentation index
|
||||||
|
│ ├── 01-conceptos/ # Fundamental concepts and requirements
|
||||||
|
│ ├── 02-tecnico/ # Technical documentation
|
||||||
|
│ ├── 03-analisis/ # Comparative analysis
|
||||||
|
│ └── 04-operaciones/ # Operations and deployment
|
||||||
|
│
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── ci.yml # Gitea Actions CI pipeline
|
||||||
|
│
|
||||||
|
└── .env.example # Environment template
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env.local` (or `.env.development`) and fill in:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database (local SQLite)
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# API Keys (get from [docs/02-tecnico/apis.md](docs/02-tecnico/apis.md))
|
||||||
|
IGDB_CLIENT_ID=your_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_client_secret
|
||||||
|
RAWG_API_KEY=your_api_key
|
||||||
|
THEGAMESDB_API_KEY=your_api_key
|
||||||
|
|
||||||
|
# App Config
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
```
|
||||||
|
|
||||||
|
For production, use Gitea Secrets. See **SECURITY.md** and **[docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)**.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### General Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests (unit + config)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run backend tests only
|
||||||
|
cd backend && yarn test
|
||||||
|
|
||||||
|
# Run frontend tests only
|
||||||
|
cd frontend && yarn test:run
|
||||||
|
|
||||||
|
# Run E2E tests (requires servers running)
|
||||||
|
cd backend && yarn dev &
|
||||||
|
cd frontend && yarn dev &
|
||||||
|
yarn test:e2e
|
||||||
|
|
||||||
|
# Lint & Format
|
||||||
|
yarn lint
|
||||||
|
yarn format
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Development and Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to frontend directory
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
yarn dev
|
||||||
|
# Frontend will be available at: http://localhost:5173
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# Preview production build
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# Run frontend-specific tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run frontend tests with coverage
|
||||||
|
yarn test:coverage
|
||||||
|
|
||||||
|
# Lint frontend code
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Format frontend code
|
||||||
|
yarn format
|
||||||
|
|
||||||
|
# Type check frontend
|
||||||
|
yarn type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Development and Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to backend directory
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
yarn dev
|
||||||
|
# Backend API will be available at: http://localhost:3000
|
||||||
|
|
||||||
|
# Run backend tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run backend tests with coverage
|
||||||
|
yarn test:coverage
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
yarn test -- gamesController.spec.ts
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
yarn test:watch
|
||||||
|
|
||||||
|
# Lint backend code
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Format backend code
|
||||||
|
yarn format
|
||||||
|
|
||||||
|
# Type check backend
|
||||||
|
yarn type-check
|
||||||
|
|
||||||
|
# Database operations
|
||||||
|
yarn prisma:migrate
|
||||||
|
yarn prisma:generate
|
||||||
|
yarn prisma:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test backend API endpoints
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Test games endpoint
|
||||||
|
curl http://localhost:3000/api/games
|
||||||
|
|
||||||
|
# Test metadata search
|
||||||
|
curl "http://localhost:3000/api/metadata/search?q=Mario"
|
||||||
|
|
||||||
|
# Test ROM scanning
|
||||||
|
curl -X POST http://localhost:3000/api/roms/scan -d '{"path":"/path/to/roms"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't start
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: EADDRINUSE: address already in use :::3000
|
||||||
|
→ Kill process on port 3000: lsof -i :3000 | grep LISTEN | awk '{print $2}' | xargs kill -9
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests failing with "DATABASE_URL not found"
|
||||||
|
|
||||||
|
```
|
||||||
|
→ Make sure backend/.env exists with DATABASE_URL
|
||||||
|
→ Or: DATABASE_URL="file:./test.db" yarn test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Metadata search returns no results
|
||||||
|
|
||||||
|
```
|
||||||
|
→ Check that API keys are correct ([docs/02-tecnico/apis.md](docs/02-tecnico/apis.md))
|
||||||
|
→ Check logs: tail -f backend/logs/*.log
|
||||||
|
→ Test with: curl http://localhost:3000/api/metadata/search\?q\=Mario
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend can't reach backend
|
||||||
|
|
||||||
|
```
|
||||||
|
→ Make sure backend is running on http://localhost:3000
|
||||||
|
→ Check frontend/.env has: VITE_API_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
For detailed architecture and decisions, see [docs/01-conceptos/architecture.md](docs/01-conceptos/architecture.md).
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Node.js, Fastify, Prisma ORM, SQLite, TypeScript
|
||||||
|
- **Frontend:** Next.js 16, React 19, TypeScript, Shadcn UI, Tailwind CSS 4
|
||||||
|
- **Testing:** Vitest (unit), Playwright (E2E)
|
||||||
|
- **APIs:** IGDB (OAuth), RAWG, TheGamesDB
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **No data leaves your system** — Everything stored locally
|
||||||
|
- **API keys stored securely** — Via `.env.local` or Gitea Secrets
|
||||||
|
- **Input validation** — All user input validated with Zod
|
||||||
|
- **CORS configured** — Only allows frontend origin
|
||||||
|
|
||||||
|
For security guidelines, see [SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[SECURITY.md](SECURITY.md)** — Security policies and best practices
|
||||||
|
- **[docs/README.md](docs/README.md)** — Documentation index and navigation guide
|
||||||
|
- **[docs/01-conceptos/requirements.md](docs/01-conceptos/requirements.md)** — Project requirements and use cases
|
||||||
|
- **[docs/01-conceptos/architecture.md](docs/01-conceptos/architecture.md)** — System architecture and design decisions
|
||||||
|
- **[docs/01-conceptos/data-model.md](docs/01-conceptos/data-model.md)** — Database schema and entities
|
||||||
|
- **[docs/02-tecnico/apis.md](docs/02-tecnico/apis.md)** — APIs configuration and integration guide
|
||||||
|
- **[docs/02-tecnico/frontend.md](docs/02-tecnico/frontend.md)** — Complete frontend architecture and implementation
|
||||||
|
- **[docs/03-analisis/competitive-analysis.md](docs/03-analisis/competitive-analysis.md)** — Market analysis and competitive research
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Adding a new feature
|
||||||
|
|
||||||
|
1. Create test file: `tests/feature.spec.ts` (TDD)
|
||||||
|
2. Write failing test
|
||||||
|
3. Implement feature
|
||||||
|
4. Run tests: `yarn test`
|
||||||
|
5. Run lint: `yarn lint`
|
||||||
|
6. Commit: `git commit -m "feat: add feature description"`
|
||||||
|
|
||||||
|
### Running Gitea Actions locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The CI workflow is in .gitea/workflows/ci.yml
|
||||||
|
# It runs automatically on push/PR to main or develop
|
||||||
|
# To test locally:
|
||||||
|
|
||||||
|
# 1. Lint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# 2. Backend tests
|
||||||
|
cd backend && yarn test
|
||||||
|
|
||||||
|
# 3. Frontend tests
|
||||||
|
cd frontend && yarn test:run
|
||||||
|
|
||||||
|
# 4. E2E tests (requires servers running)
|
||||||
|
cd backend && yarn dev &
|
||||||
|
cd frontend && yarn dev &
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests welcome. Please:
|
||||||
|
|
||||||
|
1. Run `yarn test` before pushing
|
||||||
|
2. Run `yarn format` to auto-format code
|
||||||
|
3. Add tests for new features
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (or choose your license)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- **Docs:** See [/docs](/docs) folder
|
||||||
|
- **Issues:** Report bugs on this repo
|
||||||
|
- **Discussions:** Use repo discussions for questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** MVP (v1.0.0) — Landing page completa con estética Mass Effect-inspired
|
||||||
|
**Last updated:** 2026-02-23
|
||||||
|
**Test coverage:** 122+ unit tests + 15 E2E tests ✅
|
||||||
|
**Documentation:** Frontend landing page documentado ✅
|
||||||
|
|||||||
99
SECURITY.md
Normal file
99
SECURITY.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting Security Vulnerabilities
|
||||||
|
|
||||||
|
If you discover a security vulnerability in Quasar, please email security@quasar.local with:
|
||||||
|
|
||||||
|
- Description of the vulnerability
|
||||||
|
- Steps to reproduce
|
||||||
|
- Potential impact
|
||||||
|
- Suggested fix (if any)
|
||||||
|
|
||||||
|
We'll acknowledge your report within 48 hours and work on a fix.
|
||||||
|
|
||||||
|
## Environment Variables & Secrets
|
||||||
|
|
||||||
|
**IMPORTANT:** Never commit `.env` files to the repository.
|
||||||
|
|
||||||
|
### Sensitive Variables
|
||||||
|
|
||||||
|
- `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` — Twitch OAuth credentials
|
||||||
|
- `RAWG_API_KEY` — RAWG API key (rate limited)
|
||||||
|
- `THEGAMESDB_API_KEY` — TheGamesDB key
|
||||||
|
- `DATABASE_URL` — SQLite file path (contains password if using remote DB)
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. Use `.env.local` or `.env.{NODE_ENV}.local` for local development
|
||||||
|
2. Never log or print secrets
|
||||||
|
3. Use GitHub/Gitea Secrets for CI/CD pipelines
|
||||||
|
4. Rotate keys regularly
|
||||||
|
5. Use separate keys for development, staging, production
|
||||||
|
|
||||||
|
## CI/CD Secrets (Gitea Actions)
|
||||||
|
|
||||||
|
For automated testing in Gitea Actions, store API keys as repository secrets:
|
||||||
|
|
||||||
|
### Setup Instructions
|
||||||
|
|
||||||
|
1. Go to your Gitea repository settings
|
||||||
|
- Navigate to: `https://your-gitea-instance/your-org/quasar/settings/secrets/actions`
|
||||||
|
2. Click "New Secret" for each credential:
|
||||||
|
- **Name:** `IGDB_CLIENT_ID` → **Value:** Your Client ID from Twitch
|
||||||
|
- **Name:** `IGDB_CLIENT_SECRET` → **Value:** Your Client Secret from Twitch
|
||||||
|
- **Name:** `RAWG_API_KEY` → **Value:** Your RAWG API key
|
||||||
|
- **Name:** `THEGAMESDB_API_KEY` → **Value:** Your TheGamesDB API key
|
||||||
|
3. Commit `.gitea/workflows/ci.yml` to trigger CI pipeline
|
||||||
|
|
||||||
|
### How Secrets Are Used
|
||||||
|
|
||||||
|
The CI workflow (`.gitea/workflows/ci.yml`) automatically:
|
||||||
|
|
||||||
|
- Runs **lint** on every push and pull request
|
||||||
|
- Runs **backend tests** (Vitest) with `DATABASE_URL=file:./test.db`
|
||||||
|
- Runs **frontend tests** (Vitest)
|
||||||
|
- Runs **E2E tests** (Playwright) with API key secrets injected as environment variables
|
||||||
|
- **Fails the build** if any tests fail (prevents broken code from being merged)
|
||||||
|
|
||||||
|
### Security Notes
|
||||||
|
|
||||||
|
- Secrets are **encrypted at rest** in Gitea
|
||||||
|
- Secrets are **masked in logs** (never printed to console)
|
||||||
|
- Only accessible in CI/CD contexts (not in local development)
|
||||||
|
- Same secrets work for both testing and production deployments
|
||||||
|
|
||||||
|
## Input Validation & Sanitization
|
||||||
|
|
||||||
|
All user inputs are validated using **Zod** schemas:
|
||||||
|
|
||||||
|
- Backend: `src/validators/*.ts` define strict schemas
|
||||||
|
- Frontend: React Hook Form + Zod validation
|
||||||
|
- Game titles, ROM file paths, and user uploads are sanitized
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
API calls to metadata services are rate-limited:
|
||||||
|
|
||||||
|
- IGDB: 4 requests/second
|
||||||
|
- RAWG: 20 requests/second (free tier)
|
||||||
|
- TheGamesDB: 1 request/second
|
||||||
|
|
||||||
|
## Database Security
|
||||||
|
|
||||||
|
SQLite is used for MVP. For production:
|
||||||
|
|
||||||
|
- Consider PostgreSQL or MySQL
|
||||||
|
- Enable encrypted connections (SSL/TLS)
|
||||||
|
- Use connection pooling (current: Prisma with pool settings)
|
||||||
|
- Regular backups
|
||||||
|
|
||||||
|
## CORS & CSP
|
||||||
|
|
||||||
|
Configure appropriate CORS headers in backend:
|
||||||
|
|
||||||
|
- Frontend origin: `http://localhost:3000` (dev), `https://yourdomain.com` (prod)
|
||||||
|
- Content Security Policy headers recommended for production
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- v1.0.0 (2026-02-12): Initial security guidelines
|
||||||
@@ -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",
|
||||||
|
|||||||
BIN
backend/prisma/test.db
Normal file
BIN
backend/prisma/test.db
Normal file
Binary file not shown.
@@ -4,6 +4,9 @@ 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';
|
||||||
|
import romsRoutes from './routes/roms';
|
||||||
|
import metadataRoutes from './routes/metadata';
|
||||||
|
|
||||||
export function buildApp(): FastifyInstance {
|
export function buildApp(): FastifyInstance {
|
||||||
const app: FastifyInstance = Fastify({
|
const app: FastifyInstance = Fastify({
|
||||||
@@ -15,6 +18,9 @@ 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' });
|
||||||
|
void app.register(romsRoutes, { prefix: '/api' });
|
||||||
|
void app.register(metadataRoutes, { 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
|
||||||
|
*/
|
||||||
96
backend/src/controllers/romsController.ts
Normal file
96
backend/src/controllers/romsController.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { prisma } from '../plugins/prisma';
|
||||||
|
|
||||||
|
export class RomsController {
|
||||||
|
/**
|
||||||
|
* Listar todos los ROMs con sus juegos asociados
|
||||||
|
*/
|
||||||
|
static async listRoms() {
|
||||||
|
return await prisma.romFile.findMany({
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
filename: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener un ROM por ID con su juego asociado
|
||||||
|
*/
|
||||||
|
static async getRomById(id: string) {
|
||||||
|
const rom = await prisma.romFile.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rom) {
|
||||||
|
throw new Error('ROM no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return rom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vincular un juego a un ROM existente
|
||||||
|
*/
|
||||||
|
static async linkGameToRom(romId: string, gameId: string) {
|
||||||
|
// Validar que el ROM existe
|
||||||
|
const rom = await prisma.romFile.findUnique({
|
||||||
|
where: { id: romId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rom) {
|
||||||
|
throw new Error('ROM no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar que el juego existe
|
||||||
|
const game = await prisma.game.findUnique({
|
||||||
|
where: { id: gameId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
throw new Error('Juego no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar el ROM con el nuevo gameId
|
||||||
|
return await prisma.romFile.update({
|
||||||
|
where: { id: romId },
|
||||||
|
data: {
|
||||||
|
gameId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
game: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un ROM por ID
|
||||||
|
*/
|
||||||
|
static async deleteRom(id: string) {
|
||||||
|
// Validar que el ROM existe
|
||||||
|
const rom = await prisma.romFile.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rom) {
|
||||||
|
throw new Error('ROM no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar el ROM
|
||||||
|
await prisma.romFile.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { message: 'ROM 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
|
||||||
|
*/
|
||||||
48
backend/src/routes/metadata.ts
Normal file
48
backend/src/routes/metadata.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import * as metadataService from '../services/metadataService';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
// Esquema de validación para parámetros de búsqueda
|
||||||
|
const searchMetadataSchema = z.object({
|
||||||
|
q: z.string().min(1, 'El parámetro de búsqueda es requerido'),
|
||||||
|
platform: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function metadataRoutes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* GET /api/metadata/search?q=query&platform=optional
|
||||||
|
* Buscar metadata de juegos
|
||||||
|
*/
|
||||||
|
app.get<{ Querystring: any; Reply: any[] }>('/metadata/search', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Validar parámetros de query con Zod
|
||||||
|
const validated = searchMetadataSchema.parse(request.query);
|
||||||
|
|
||||||
|
// Llamar a metadataService
|
||||||
|
const result = await metadataService.enrichGame({
|
||||||
|
title: validated.q,
|
||||||
|
platform: validated.platform,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si hay resultado, devolver como array; si no, devolver array vacío
|
||||||
|
return reply.code(200).send(result ? [result] : []);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Parámetros de búsqueda inválidos',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default metadataRoutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
95
backend/src/routes/roms.ts
Normal file
95
backend/src/routes/roms.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { RomsController } from '../controllers/romsController';
|
||||||
|
import { linkGameSchema } from '../validators/romValidator';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
async function romsRoutes(app: FastifyInstance) {
|
||||||
|
/**
|
||||||
|
* GET /api/roms
|
||||||
|
* Listar todos los ROMs
|
||||||
|
*/
|
||||||
|
app.get<{ Reply: any[] }>('/roms', async (request, reply) => {
|
||||||
|
const roms = await RomsController.listRoms();
|
||||||
|
return reply.code(200).send(roms);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/roms/:id
|
||||||
|
* Obtener un ROM por ID
|
||||||
|
*/
|
||||||
|
app.get<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const rom = await RomsController.getRomById(request.params.id);
|
||||||
|
return reply.code(200).send(rom);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('no encontrado')) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: 'ROM no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/roms/:id/game
|
||||||
|
* Vincular un juego a un ROM
|
||||||
|
*/
|
||||||
|
app.put<{ Params: { id: string }; Body: any; Reply: any }>(
|
||||||
|
'/roms/:id/game',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
// Validar entrada con Zod
|
||||||
|
const validated = linkGameSchema.parse(request.body);
|
||||||
|
const rom = await RomsController.linkGameToRom(request.params.id, validated.gameId);
|
||||||
|
return reply.code(200).send(rom);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Validación fallida',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message.includes('ROM no encontrado')) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
error: 'ROM no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error.message.includes('Juego no encontrado')) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: 'Game ID inválido o no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/roms/:id
|
||||||
|
* Eliminar un ROM
|
||||||
|
*/
|
||||||
|
app.delete<{ Params: { id: string }; Reply: any }>('/roms/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await RomsController.deleteRom(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: 'ROM no encontrado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default romsRoutes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
15
backend/src/validators/romValidator.ts
Normal file
15
backend/src/validators/romValidator.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Esquema para vincular un juego a un ROM
|
||||||
|
export const linkGameSchema = z.object({
|
||||||
|
gameId: z.string().min(1, 'El ID del juego es requerido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tipo TypeScript derivado del esquema
|
||||||
|
export type LinkGameInput = z.infer<typeof linkGameSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
258
backend/tests/routes/games.spec.ts
Normal file
258
backend/tests/routes/games.spec.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
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
|
||||||
|
// Orden importante: relaciones de FK primero
|
||||||
|
await prisma.romFile.deleteMany();
|
||||||
|
await prisma.purchase.deleteMany();
|
||||||
|
await prisma.gamePlatform.deleteMany();
|
||||||
|
await prisma.artwork.deleteMany();
|
||||||
|
await prisma.priceHistory.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
|
||||||
|
*/
|
||||||
101
backend/tests/routes/metadata.spec.ts
Normal file
101
backend/tests/routes/metadata.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import * as metadataService from '../../src/services/metadataService';
|
||||||
|
|
||||||
|
describe('Metadata API', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = buildApp();
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/metadata/search', () => {
|
||||||
|
it('debería devolver resultados cuando se busca un juego existente', async () => {
|
||||||
|
const mockResults = [
|
||||||
|
{
|
||||||
|
source: 'igdb',
|
||||||
|
externalIds: { igdb: 1 },
|
||||||
|
title: 'The Legend of Zelda',
|
||||||
|
slug: 'the-legend-of-zelda',
|
||||||
|
releaseDate: '1986-02-21',
|
||||||
|
genres: ['Adventure'],
|
||||||
|
coverUrl: 'https://example.com/cover.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(mockResults[0]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=zelda',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body.length).toBeGreaterThan(0);
|
||||||
|
expect(body[0].title).toContain('Zelda');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver lista vacía cuando no hay resultados', async () => {
|
||||||
|
vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=nonexistentgame12345',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si falta el parámetro query', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toHaveProperty('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si el parámetro query está vacío', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería pasar el parámetro platform a enrichGame si se proporciona', async () => {
|
||||||
|
const enrichSpy = vi.spyOn(metadataService, 'enrichGame').mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/metadata/search?q=mario&platform=Nintendo%2064',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(enrichSpy).toHaveBeenCalledWith({
|
||||||
|
title: 'mario',
|
||||||
|
platform: 'Nintendo 64',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-11
|
||||||
|
*/
|
||||||
295
backend/tests/routes/roms.spec.ts
Normal file
295
backend/tests/routes/roms.spec.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { buildApp } from '../../src/app';
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { prisma } from '../../src/plugins/prisma';
|
||||||
|
|
||||||
|
describe('ROMs API', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = buildApp();
|
||||||
|
await app.ready();
|
||||||
|
// Limpiar base de datos antes de cada test (eliminar ROMs primero por foreign key)
|
||||||
|
await prisma.romFile.deleteMany();
|
||||||
|
await prisma.gamePlatform.deleteMany();
|
||||||
|
await prisma.purchase.deleteMany();
|
||||||
|
await prisma.artwork.deleteMany();
|
||||||
|
await prisma.priceHistory.deleteMany();
|
||||||
|
await prisma.game.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/roms', () => {
|
||||||
|
it('debería devolver una lista vacía cuando no hay ROMs', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver una lista de ROMs con sus propiedades', async () => {
|
||||||
|
// Crear un ROM de prueba
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/games/',
|
||||||
|
filename: 'game.zip',
|
||||||
|
checksum: 'abc123def456',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body.length).toBe(1);
|
||||||
|
expect(body[0].id).toBe(rom.id);
|
||||||
|
expect(body[0].filename).toBe('game.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería incluir información del juego asociado', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Test Game',
|
||||||
|
slug: 'test-game',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'test-with-game.zip',
|
||||||
|
checksum: 'checksum-game-123',
|
||||||
|
size: 2048,
|
||||||
|
format: 'zip',
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
// Buscar el ROM que creamos por checksum
|
||||||
|
const createdRom = body.find((r: any) => r.checksum === 'checksum-game-123');
|
||||||
|
expect(createdRom).toBeDefined();
|
||||||
|
expect(createdRom.game).toBeDefined();
|
||||||
|
expect(createdRom.game.title).toBe('Test Game');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/roms/:id', () => {
|
||||||
|
it('debería retornar un ROM existente', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'game1.zip',
|
||||||
|
checksum: 'checksum1',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/roms/${rom.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.id).toBe(rom.id);
|
||||||
|
expect(body.filename).toBe('game1.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería retornar 404 si el ROM no existe', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/roms/non-existing-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
expect(res.json()).toHaveProperty('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería incluir el juego asociado al ROM', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Zelda',
|
||||||
|
slug: 'zelda',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'zelda.zip',
|
||||||
|
checksum: 'checksum2',
|
||||||
|
size: 2048,
|
||||||
|
format: 'zip',
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/roms/${rom.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.game).toBeDefined();
|
||||||
|
expect(body.game.title).toBe('Zelda');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/roms/:id/game', () => {
|
||||||
|
it('debería vincular un juego a un ROM existente', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Mario',
|
||||||
|
slug: 'mario',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'mario.zip',
|
||||||
|
checksum: 'checksum3',
|
||||||
|
size: 512,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/roms/${rom.id}/game`,
|
||||||
|
payload: {
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.gameId).toBe(game.id);
|
||||||
|
expect(body.game.title).toBe('Mario');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si el gameId es inválido', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'game.zip',
|
||||||
|
checksum: 'checksum4',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/roms/${rom.id}/game`,
|
||||||
|
payload: {
|
||||||
|
gameId: 'invalid-game-id',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 404 si el ROM no existe', async () => {
|
||||||
|
const game = await prisma.game.create({
|
||||||
|
data: {
|
||||||
|
title: 'Test',
|
||||||
|
slug: 'test',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/roms/non-existing-id/game',
|
||||||
|
payload: {
|
||||||
|
gameId: game.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 400 si falta gameId', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'game.zip',
|
||||||
|
checksum: 'checksum5',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/roms/${rom.id}/game`,
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/roms/:id', () => {
|
||||||
|
it('debería eliminar un ROM existente', async () => {
|
||||||
|
const rom = await prisma.romFile.create({
|
||||||
|
data: {
|
||||||
|
path: '/roms/',
|
||||||
|
filename: 'delete-me.zip',
|
||||||
|
checksum: 'checksum6',
|
||||||
|
size: 1024,
|
||||||
|
format: 'zip',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/roms/${rom.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
|
||||||
|
// Verificar que el ROM fue eliminado
|
||||||
|
const deletedRom = await prisma.romFile.findUnique({
|
||||||
|
where: { id: rom.id },
|
||||||
|
});
|
||||||
|
expect(deletedRom).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debería devolver 404 si el ROM no existe', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/roms/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
21
backend/tests/setup.ts
Normal file
21
backend/tests/setup.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
// Cargar variables de entorno desde .env
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Ejecutar migraciones de Prisma antes de los tests
|
||||||
|
try {
|
||||||
|
execSync('npx prisma migrate deploy', {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to run Prisma migrations:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadatos:
|
||||||
|
* Autor: GitHub Copilot
|
||||||
|
* Última actualización: 2026-02-12
|
||||||
|
*/
|
||||||
@@ -11,9 +11,11 @@ export default defineConfig({
|
|||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/**/*.spec.ts'],
|
include: ['tests/**/*.spec.ts'],
|
||||||
globals: false,
|
globals: false,
|
||||||
|
threads: false, // Desactivar parallelización para evitar contaminación de BD
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'c8',
|
provider: 'c8',
|
||||||
reporter: ['text', 'lcov'],
|
reporter: ['text', 'lcov'],
|
||||||
},
|
},
|
||||||
|
setupFiles: ['./tests/setup.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,222 @@
|
|||||||
# Comparativa de APIs — cobertura, límites, coste y calidad
|
# APIs del Sistema — Guía completa
|
||||||
|
|
||||||
**Introducción**
|
Este documento integra toda la información sobre APIs del sistema: obtención de claves, prioridades, estrategias, comparación y configuración.
|
||||||
Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots, géneros, desarrolladores), y datos de precio/ofertas. Las decisiones de integración deben priorizar cobertura, coste (preferencia: gratuito), calidad y facilidad de uso.
|
|
||||||
|
|
||||||
**Nota:** límites y condiciones pueden cambiar — verificar TOS antes de integración.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Resumen por API
|
## Tabla de Contenidos
|
||||||
|
|
||||||
### IGDB (Internet Games Database)
|
1. [APIs priorizadas (MVP)](#apis-priorizadas-mvp)
|
||||||
|
2. [Obtención de claves](#obtención-de-claves)
|
||||||
|
3. [Guía de integración](#guía-de-integración)
|
||||||
|
4. [Comparación detallada](#comparación-detallada)
|
||||||
|
5. [Estrategias técnicas](#estrategias-técnicas)
|
||||||
|
6. [Configuración y despliegue](#configuración-y-despliegue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## APIs priorizadas (MVP)
|
||||||
|
|
||||||
|
### Prioridad Alta
|
||||||
|
|
||||||
|
1. **IGDB (Internet Game Database)** - Calidad superior, amplia cobertura
|
||||||
|
2. **RAWG (Rawg.io)** - Buena cobertura, datos de tiendas
|
||||||
|
|
||||||
|
### Prioridad Media
|
||||||
|
|
||||||
|
3. **TheGamesDB** - Artwork comunitario
|
||||||
|
4. **ScreenScraper** - Media específica para ROMs
|
||||||
|
|
||||||
|
### Prioridad Baja (para futuras versiones)
|
||||||
|
|
||||||
|
5. **PriceCharting** - Precios físicos
|
||||||
|
6. **IsThereAnyDeal** - Ofertas digitales
|
||||||
|
7. **MobyGames** - Datos históricos detallados
|
||||||
|
8. **eBay** - Datos de mercado
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Obtención de claves
|
||||||
|
|
||||||
|
### IGDB (Internet Game Database)
|
||||||
|
|
||||||
|
IGDB usa **OAuth 2.0 via Twitch**. Steps:
|
||||||
|
|
||||||
|
1. Go to [Twitch Developer Console](https://dev.twitch.tv/console/apps)
|
||||||
|
2. Sign in with your Twitch account (create one if needed)
|
||||||
|
3. Click "Create Application"
|
||||||
|
- Name: "Quasar" (or your app name)
|
||||||
|
- Category: Select relevant category
|
||||||
|
- Accept terms, click Create
|
||||||
|
4. You'll see:
|
||||||
|
- **Client ID** — Copy this
|
||||||
|
- Click "New Secret" to generate **Client Secret** — Copy this
|
||||||
|
5. Go to Settings → OAuth Redirect URLs
|
||||||
|
- Add: `http://localhost:3000/oauth/callback` (development)
|
||||||
|
- For production: `https://yourdomain.com/oauth/callback`
|
||||||
|
6. In your `.env` file:
|
||||||
|
```
|
||||||
|
IGDB_CLIENT_ID=your_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_client_secret
|
||||||
|
```
|
||||||
|
7. Start Quasar, it will use IGDB automatically
|
||||||
|
|
||||||
|
**Rate Limit:** 4 requests/second
|
||||||
|
|
||||||
|
### RAWG (Rawg.io)
|
||||||
|
|
||||||
|
RAWG has a simpler **API Key** approach:
|
||||||
|
|
||||||
|
1. Go to [RAWG Settings](https://rawg.io/settings/account)
|
||||||
|
2. Sign up if needed, then login
|
||||||
|
3. Find "API Key" section
|
||||||
|
4. Click "Create new key" (if needed) or copy existing key
|
||||||
|
5. In your `.env` file:
|
||||||
|
```
|
||||||
|
RAWG_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
6. Start Quasar
|
||||||
|
|
||||||
|
**Rate Limit:** 20 requests/second (free tier)
|
||||||
|
|
||||||
|
**Note:** RAWG requires attribution in UI (include "Powered by RAWG" somewhere visible)
|
||||||
|
|
||||||
|
### TheGamesDB (thegamesdb.net)
|
||||||
|
|
||||||
|
TheGamesDB uses a simple **API Key**:
|
||||||
|
|
||||||
|
1. Go to [TheGamesDB API](https://thegamesdb.net/api)
|
||||||
|
2. Find "API Key" section (free registration required)
|
||||||
|
3. Register or login
|
||||||
|
4. Copy your API key
|
||||||
|
5. In your `.env` file:
|
||||||
|
```
|
||||||
|
THEGAMESDB_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
6. Start Quasar
|
||||||
|
|
||||||
|
**Rate Limit:** 1 request/second (free tier)
|
||||||
|
|
||||||
|
### ScreenScraper
|
||||||
|
|
||||||
|
ScreenScraper requiere cuenta y modelo de donación:
|
||||||
|
|
||||||
|
1. Go to [ScreenScraper](https://www.screenscraper.fr/)
|
||||||
|
2. Create account
|
||||||
|
3. Niveles de donación ofrecen límites distintos (ej.: 50.000 scrapes/día en nivel Bronze)
|
||||||
|
4. En tu `.env` file:
|
||||||
|
```
|
||||||
|
SCREENSCRAPER_USERNAME=your_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guía de integración
|
||||||
|
|
||||||
|
### IGDB
|
||||||
|
|
||||||
|
- **Obtener credenciales**: registrar una app en Twitch Developer Console para obtener `CLIENT_ID` y `CLIENT_SECRET`. Obtener token con grant type `client_credentials` (POST a `https://id.twitch.tv/oauth2/token`).
|
||||||
|
|
||||||
|
- **Endpoints principales**: `POST https://api.igdb.com/v4/games` (consulta flexible via body con sintaxis IGDB), `POST https://api.igdb.com/v4/covers`, `POST https://api.igdb.com/v4/platforms`.
|
||||||
|
|
||||||
|
- **Ejemplo (buscar)**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Obtener token
|
||||||
|
curl -X POST 'https://id.twitch.tv/oauth2/token?client_id=$IGDB_CLIENT_ID&client_secret=$IGDB_CLIENT_SECRET&grant_type=client_credentials'
|
||||||
|
|
||||||
|
# Buscar juegos
|
||||||
|
curl -X POST 'https://api.igdb.com/v4/games' \
|
||||||
|
-H "Client-ID: $IGDB_CLIENT_ID" \
|
||||||
|
-H "Authorization: Bearer $IGDB_TOKEN" \
|
||||||
|
-H 'Accept: application/json' \
|
||||||
|
--data 'fields id,name,first_release_date,platforms.name,genres.name,cover.url; search "zelda"; limit 5;'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Respuesta (esquemática)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"name": "Ejemplo",
|
||||||
|
"first_release_date": 1459468800,
|
||||||
|
"platforms": [{ "name": "Nintendo Switch" }],
|
||||||
|
"cover": { "url": "//images.igdb.com/...jpg" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Límites y manejo**: la API puede devolver `429` o cabeceras de límite; implementar retries exponenciales (ej. 3 intentos) y respetar `Retry-After`. Implementar circuit breaker si la API falla repetidamente.
|
||||||
|
- **Atribución**: mostrar origen de datos (ej. "Datos: IGDB") según términos del servicio.
|
||||||
|
|
||||||
|
### RAWG
|
||||||
|
|
||||||
|
- **Obtener credenciales**: registrarse en RAWG para obtener `RAWG_API_KEY` (https://rawg.io/apidocs).
|
||||||
|
- **Endpoints principales**: `GET https://api.rawg.io/api/games?key=API_KEY&search=...`, `GET https://api.rawg.io/api/games/{id}`.
|
||||||
|
- **Ejemplo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=zelda&page_size=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Respuesta (esquemática)**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 100,
|
||||||
|
"results": [
|
||||||
|
{ "id": 3498, "name": "GTA V", "released": "2013-09-17", "background_image": "https://..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Límites y manejo**: RAWG suele tener límites por clave/plan; cachear y fallback a otros proveedores si falla.
|
||||||
|
- **Atribución**: revisar condiciones y mostrar HTTP o texto de fuente si es requerido por el proveedor.
|
||||||
|
|
||||||
|
### TheGamesDB
|
||||||
|
|
||||||
|
- **Obtener credenciales**: crear cuenta y generar API Key en https://thegamesdb.net.
|
||||||
|
- **Endpoints**: búsqueda por nombre y detalles (`/v1/Games/ByGameName?name=...`, `/v1/Games/ByGameID?id=...`).
|
||||||
|
- **Ejemplo**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer $THEGAMESDB_KEY' 'https://api.thegamesdb.net/v1/Games/ByGameName?name=zelda'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Estrategia de fallback y normalización
|
||||||
|
|
||||||
|
- **Orden de prioridad**: IGDB → RAWG → TheGamesDB (configurable).
|
||||||
|
- **Normalización (mapping)**:
|
||||||
|
- `title` ← `name`
|
||||||
|
- `platform` ← `platforms[].name`
|
||||||
|
- `release_date` ← `first_release_date` / `released` → convertir a ISO 8601
|
||||||
|
- `genres` ← `genres[].name`
|
||||||
|
- `cover_url` ← `cover.url` / `background_image`
|
||||||
|
- `external_ids` ← `{ igdb: id, rawg: id, thegamesdb: id }`
|
||||||
|
|
||||||
|
- **Fallback**: si IGDB no tiene portada, intentar RAWG; si falla, usar TheGamesDB. Registrar la fuente usada.
|
||||||
|
|
||||||
|
### Caché y almacenamiento de artwork
|
||||||
|
|
||||||
|
- **Caché metadata**: LRU en memoria o Redis con TTL (por ejemplo 24h) para evitar sobrecargar APIs.
|
||||||
|
- **Almacenamiento de imágenes**: descargar y optimizar con `sharp` (crear versiones: thumb, medium), almacenar en `storage/artwork/{gameId}/cover.jpg` o S3.
|
||||||
|
- **Servicio proxy**: servir imágenes desde backend para no exponer keys ni URLs externas.
|
||||||
|
|
||||||
|
### Manejo de errores y resiliencia
|
||||||
|
|
||||||
|
- Implementar **retries** exponenciales con jitter (3 intentos).
|
||||||
|
- Implementar **circuit breaker** para desconectar llamadas a un proveedor fuera de servicio por N minutos.
|
||||||
|
- Limitar concurrencia por proveedor (p. ej. 5 llamadas simultáneas) y usar colas para trabajos masivos (enriquecimiento masivo).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparación detallada
|
||||||
|
|
||||||
|
### Resumen por API
|
||||||
|
|
||||||
|
#### IGDB (Internet Games Database)
|
||||||
|
|
||||||
- **Resumen:** Base de datos muy completa (propiedad de Twitch/Amazon) con endpoints para juegos, covers, screenshots, plataformas, ratings, compañías y más.
|
- **Resumen:** Base de datos muy completa (propiedad de Twitch/Amazon) con endpoints para juegos, covers, screenshots, plataformas, ratings, compañías y más.
|
||||||
- **Autenticación / Requisitos:** OAuth vía Twitch (Client ID + Client Secret → token) — requiere cuenta Twitch y 2FA para registrar apps.
|
- **Autenticación / Requisitos:** OAuth vía Twitch (Client ID + Client Secret → token) — requiere cuenta Twitch y 2FA para registrar apps.
|
||||||
@@ -21,9 +228,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Gratuito para uso no comercial; acuerdos comerciales para partners (atribución en caso de partnership).
|
- **Costes / modelo:** Gratuito para uso no comercial; acuerdos comerciales para partners (atribución en caso de partnership).
|
||||||
- **Enlace:** https://api-docs.igdb.com/
|
- **Enlace:** https://api-docs.igdb.com/
|
||||||
|
|
||||||
---
|
#### RAWG
|
||||||
|
|
||||||
### RAWG
|
|
||||||
|
|
||||||
- **Resumen:** Gran base de datos (medio millón de juegos), buena para metadata general y enlaces a tiendas.
|
- **Resumen:** Gran base de datos (medio millón de juegos), buena para metadata general y enlaces a tiendas.
|
||||||
- **Autenticación / Requisitos:** API key en query string (`key=YOUR_API_KEY`).
|
- **Autenticación / Requisitos:** API key en query string (`key=YOUR_API_KEY`).
|
||||||
@@ -35,9 +240,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free tier para proyectos personales; planes comerciales (pago mensual) para uso en productos con gran tráfico.
|
- **Costes / modelo:** Free tier para proyectos personales; planes comerciales (pago mensual) para uso en productos con gran tráfico.
|
||||||
- **Enlace:** https://rawg.io/apidocs
|
- **Enlace:** https://rawg.io/apidocs
|
||||||
|
|
||||||
---
|
#### TheGamesDB
|
||||||
|
|
||||||
### TheGamesDB
|
|
||||||
|
|
||||||
- **Resumen:** Base de datos comunitaria para juegos y artwork, con API pública v2.
|
- **Resumen:** Base de datos comunitaria para juegos y artwork, con API pública v2.
|
||||||
- **Autenticación / Requisitos:** Registro y uso de API key (ver docs); repositorio público del proyecto (GPLv3 para el código del servidor).
|
- **Autenticación / Requisitos:** Registro y uso de API key (ver docs); repositorio público del proyecto (GPLv3 para el código del servidor).
|
||||||
@@ -48,9 +251,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Cláusula clave:** No documentado públicamente — verificar con el equipo de TheGamesDB antes de uso comercial/redistribución.
|
- **Cláusula clave:** No documentado públicamente — verificar con el equipo de TheGamesDB antes de uso comercial/redistribución.
|
||||||
- **Enlace:** https://api.thegamesdb.net/
|
- **Enlace:** https://api.thegamesdb.net/
|
||||||
|
|
||||||
---
|
#### ScreenScraper
|
||||||
|
|
||||||
### ScreenScraper
|
|
||||||
|
|
||||||
- **Resumen:** Servicio francés orientado a frontends, con enorme cantidad de media y opciones de scraping.
|
- **Resumen:** Servicio francés orientado a frontends, con enorme cantidad de media y opciones de scraping.
|
||||||
- **Autenticación / Requisitos:** Cuenta en ScreenScraper; modelo de soporte/donación que habilita límites mayores.
|
- **Autenticación / Requisitos:** Cuenta en ScreenScraper; modelo de soporte/donación que habilita límites mayores.
|
||||||
@@ -62,9 +263,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Donación / suscripción para aumentar cuotas y velocidad.
|
- **Costes / modelo:** Donación / suscripción para aumentar cuotas y velocidad.
|
||||||
- **Enlace:** https://www.screenscraper.fr/
|
- **Enlace:** https://www.screenscraper.fr/
|
||||||
|
|
||||||
---
|
#### MobyGames
|
||||||
|
|
||||||
### MobyGames
|
|
||||||
|
|
||||||
- **Resumen:** Base histórica con screenshots, covers, reviews y credits; muy usada por investigación y metadata profunda.
|
- **Resumen:** Base histórica con screenshots, covers, reviews y credits; muy usada por investigación y metadata profunda.
|
||||||
- **Autenticación / Requisitos:** API y/o MobyPlus; la API requiere registro y suscripción.
|
- **Autenticación / Requisitos:** API y/o MobyPlus; la API requiere registro y suscripción.
|
||||||
@@ -76,9 +275,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Acceso vía suscripción / MobyPro; contactar para condiciones comerciales.
|
- **Costes / modelo:** Acceso vía suscripción / MobyPro; contactar para condiciones comerciales.
|
||||||
- **Enlace:** https://www.mobygames.com/api/subscribe/
|
- **Enlace:** https://www.mobygames.com/api/subscribe/
|
||||||
|
|
||||||
---
|
#### PriceCharting
|
||||||
|
|
||||||
### PriceCharting
|
|
||||||
|
|
||||||
- **Resumen:** Fuente especializada en historial de precios para juegos físicos y coleccionables.
|
- **Resumen:** Fuente especializada en historial de precios para juegos físicos y coleccionables.
|
||||||
- **Autenticación / Requisitos:** API documentada en el sitio; el acceso completo requiere suscripción / token pagado.
|
- **Autenticación / Requisitos:** API documentada en el sitio; el acceso completo requiere suscripción / token pagado.
|
||||||
@@ -90,9 +287,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
|
- **Costes / modelo:** Servicio comercial (licencias / API keys pagadas).
|
||||||
- **Enlace:** https://www.pricecharting.com/api-documentation
|
- **Enlace:** https://www.pricecharting.com/api-documentation
|
||||||
|
|
||||||
---
|
#### IsThereAnyDeal (Itad)
|
||||||
|
|
||||||
### IsThereAnyDeal (Itad)
|
|
||||||
|
|
||||||
- **Resumen:** Agregador de ofertas con histórico y mapeo de keys/tiendas; útil para tracking de ofertas digitales.
|
- **Resumen:** Agregador de ofertas con histórico y mapeo de keys/tiendas; útil para tracking de ofertas digitales.
|
||||||
- **Autenticación / Requisitos:** API Key (docs en https://docs.isthereanydeal.com/).
|
- **Autenticación / Requisitos:** API Key (docs en https://docs.isthereanydeal.com/).
|
||||||
@@ -104,9 +299,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free tier; acuerdos comerciales para uso intensivo.
|
- **Costes / modelo:** Free tier; acuerdos comerciales para uso intensivo.
|
||||||
- **Enlace:** https://docs.isthereanydeal.com/
|
- **Enlace:** https://docs.isthereanydeal.com/
|
||||||
|
|
||||||
---
|
#### eBay
|
||||||
|
|
||||||
### eBay
|
|
||||||
|
|
||||||
- **Resumen:** Fuente de datos de mercado (listings, precios vendidos) para estimar valor real de mercado.
|
- **Resumen:** Fuente de datos de mercado (listings, precios vendidos) para estimar valor real de mercado.
|
||||||
- **Autenticación / Requisitos:** Registro en eBay Developers Program; claves y OAuth para endpoints de venta/completed items.
|
- **Autenticación / Requisitos:** Registro en eBay Developers Program; claves y OAuth para endpoints de venta/completed items.
|
||||||
@@ -118,9 +311,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
- **Costes / modelo:** Free para desarrolladores con límites; uso intensivo o comerciales pueden requerir acuerdos o certificaciones.
|
- **Costes / modelo:** Free para desarrolladores con límites; uso intensivo o comerciales pueden requerir acuerdos o certificaciones.
|
||||||
- **Enlace:** https://developer.ebay.com/
|
- **Enlace:** https://developer.ebay.com/
|
||||||
|
|
||||||
---
|
### Tabla resumida
|
||||||
|
|
||||||
## Tabla resumida
|
|
||||||
|
|
||||||
| API | Data types | Auth | Free / Paid | Fecha verificación | Licencia / Nota legal | Notes |
|
| API | Data types | Auth | Free / Paid | Fecha verificación | Licencia / Nota legal | Notes |
|
||||||
| -------------- | ------------------------------------------------------- | -------------------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
| -------------- | ------------------------------------------------------- | -------------------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
@@ -133,9 +324,7 @@ Comparar APIs públicas y comerciales que aportan metadatos (covers, screenshots
|
|||||||
| MobyGames | screenshots, credits, covers | Subscribe / API key | Paid / subscription | 2026-02-07 | Paid/Subscribe: https://www.mobygames.com/api/subscribe/ | Access via subscription; non-commercial rate limits documented |
|
| MobyGames | screenshots, credits, covers | Subscribe / API key | Paid / subscription | 2026-02-07 | Paid/Subscribe: https://www.mobygames.com/api/subscribe/ | Access via subscription; non-commercial rate limits documented |
|
||||||
| eBay | listings, sold data | eBay Dev keys / OAuth | Free (with limits) | 2026-02-07 | TOS: https://developer.ebay.com/ | Terms restrict distribution; API License Agreement |
|
| eBay | listings, sold data | eBay Dev keys / OAuth | Free (with limits) | 2026-02-07 | TOS: https://developer.ebay.com/ | Terms restrict distribution; API License Agreement |
|
||||||
|
|
||||||
---
|
### Conclusión y recomendación para MVP
|
||||||
|
|
||||||
## Conclusión y recomendación para MVP
|
|
||||||
|
|
||||||
Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, ScreenScraper, PriceCharting, IsThereAnyDeal.**
|
Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, ScreenScraper, PriceCharting, IsThereAnyDeal.**
|
||||||
|
|
||||||
@@ -144,13 +333,171 @@ Recomiendo un **set inicial de APIs (priorizado)**: **IGDB, RAWG, TheGamesDB, Sc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Vacíos y verificación pendiente
|
## Estrategias técnicas
|
||||||
|
|
||||||
- **APIs que requieren suscripción / acuerdos comerciales:** PriceCharting (API premium, requiere suscripción), MobyGames (MobyPro/API requiere suscripción), EmuMovies (servicio comercial con TOS y cuentas), y en casos especiales eBay (certificaciones / acuerdos adicionales para ciertos permisos).
|
### Variables de entorno (ejemplos)
|
||||||
- **PriceCharting:** la documentación de la API existe pero el acceso completo está sujeto a registro/pago; no se publicó límite público durante la verificación.
|
|
||||||
- **MobyGames:** API y límites requieren suscripción/registro; hay que contactar para condiciones comerciales.
|
```
|
||||||
- **eBay:** múltiples APIs y límites por endpoint; requiere revisar caso de uso específico y cumplimiento del API License Agreement.
|
IGDB_CLIENT_ID=...
|
||||||
- **Notas:** Algunas APIs (ScreenScraper) usan modelos por donación/premium para aumentar cuotas; en APIs sin límites públicos, contactar al proveedor para confirmar condiciones.
|
IGDB_CLIENT_SECRET=...
|
||||||
|
RAWG_API_KEY=...
|
||||||
|
THEGAMESDB_API_KEY=...
|
||||||
|
SCREENSCRAPER_USERNAME=...
|
||||||
|
SCREENSCRAPER_PASSWORD=...
|
||||||
|
EXTERNAL_API_CONCURRENCY=5
|
||||||
|
```
|
||||||
|
|
||||||
|
> Nota: **Nunca** exponer estas claves en el cliente; siempre pasar por el backend.
|
||||||
|
|
||||||
|
### Normalización de datos
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NormalizedGame {
|
||||||
|
title: string;
|
||||||
|
platform: string;
|
||||||
|
release_date: string; // ISO 8601
|
||||||
|
genres: string[];
|
||||||
|
cover_url: string;
|
||||||
|
external_ids: {
|
||||||
|
igdb?: string;
|
||||||
|
rawg?: string;
|
||||||
|
thegamesdb?: string;
|
||||||
|
};
|
||||||
|
source: 'igdb' | 'rawg' | 'thegamesdb' | 'screenscraper';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo de implementación
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class MetadataService {
|
||||||
|
private apis = [
|
||||||
|
new IGDBService(),
|
||||||
|
new RAWGService(),
|
||||||
|
new TheGamesDBService(),
|
||||||
|
new ScreenScraperService(),
|
||||||
|
];
|
||||||
|
|
||||||
|
async searchGame(title: string): Promise<NormalizedGame> {
|
||||||
|
for (const api of this.apis) {
|
||||||
|
try {
|
||||||
|
const result = await api.search(title);
|
||||||
|
if (result) {
|
||||||
|
return this.normalize(result, api.getSource());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`${api.getSource()} failed:`, error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('All APIs failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalize(data: any, source: string): NormalizedGame {
|
||||||
|
return {
|
||||||
|
title: data.name || data.title,
|
||||||
|
platform: data.platforms?.[0]?.name || '',
|
||||||
|
release_date: this.normalizeDate(data.first_release_date || data.released),
|
||||||
|
genres: data.genres?.map((g: any) => g.name) || [],
|
||||||
|
cover_url: data.cover?.url || data.background_image || '',
|
||||||
|
external_ids: {
|
||||||
|
igdb: data.id,
|
||||||
|
rawg: data.id,
|
||||||
|
thegamesdb: data.id,
|
||||||
|
},
|
||||||
|
source: source as any,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración y despliegue
|
||||||
|
|
||||||
|
### Testing Without Real Keys
|
||||||
|
|
||||||
|
Para desarrollo/testing:
|
||||||
|
|
||||||
|
- Dejar API keys como `your_*_here` en `.env.local`
|
||||||
|
- Quasar will gracefully degrade and show limited metadata
|
||||||
|
- Frontend will still work with manual game entry
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
Para producción:
|
||||||
|
|
||||||
|
1. Generar nuevas claves en cada servicio (no reutilizar claves de desarrollo)
|
||||||
|
2. Almacenar claves en **Gitea Secrets** (para pipelines CI/CD automatizados)
|
||||||
|
3. O usar variables de entorno en tu proveedor de hosting
|
||||||
|
4. Rotar claves cada 3 meses
|
||||||
|
5. Monitorear límites de rate en los dashboards de los servicios
|
||||||
|
|
||||||
|
### Gitea Actions CI/CD Setup
|
||||||
|
|
||||||
|
Para habilitar pruebas automatizadas con API keys en Gitea Actions:
|
||||||
|
|
||||||
|
#### 1. Store Secrets in Gitea
|
||||||
|
|
||||||
|
Navigate to your repository settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://your-gitea-instance/your-org/quasar/settings/secrets/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Add these secrets:
|
||||||
|
|
||||||
|
- `IGDB_CLIENT_ID` (from Twitch Developer Console)
|
||||||
|
- `IGDB_CLIENT_SECRET` (from Twitch Developer Console)
|
||||||
|
- `RAWG_API_KEY` (from RAWG settings)
|
||||||
|
- `THEGAMESDB_API_KEY` (from TheGamesDB API)
|
||||||
|
- `SCREENSCRAPER_USERNAME` (from ScreenScraper)
|
||||||
|
- `SCREENSCRAPER_PASSWORD` (from ScreenScraper)
|
||||||
|
|
||||||
|
#### 2. Workflow Configuration
|
||||||
|
|
||||||
|
The `.gitea/workflows/ci.yml` workflow automatically:
|
||||||
|
|
||||||
|
- ✅ Installs dependencies
|
||||||
|
- ✅ Runs linting checks
|
||||||
|
- ✅ Executes backend tests (Vitest)
|
||||||
|
- ✅ Executes frontend tests (Vitest)
|
||||||
|
- ✅ Starts backend + frontend servers
|
||||||
|
- ✅ Runs E2E tests (Playwright) with real metadata APIs
|
||||||
|
- ✅ Uploads test reports on failure
|
||||||
|
|
||||||
|
#### 3. Testing Flow
|
||||||
|
|
||||||
|
1. **Push** code to `main` or `develop`
|
||||||
|
2. **Gitea Actions** picks up the `.gitea/workflows/ci.yml`
|
||||||
|
3. **Secrets are injected** as environment variables
|
||||||
|
4. **E2E tests** fetch real metadata from APIs (using injected secrets)
|
||||||
|
5. **Build fails** if any test fails (prevents broken code)
|
||||||
|
|
||||||
|
#### 4. Local Development
|
||||||
|
|
||||||
|
For local testing, use `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
IGDB_CLIENT_ID=your_local_id
|
||||||
|
IGDB_CLIENT_SECRET=your_local_secret
|
||||||
|
RAWG_API_KEY=your_local_key
|
||||||
|
THEGAMESDB_API_KEY=your_local_key
|
||||||
|
SCREENSCRAPER_USERNAME=your_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** CI/CD uses Gitea Secrets (not `.env` files), so never commit real credentials.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**"IGDB_CLIENT_ID not found"** → Check `.env` file exists and has correct format
|
||||||
|
|
||||||
|
**"429 Too Many Requests"** → Rate limit exceeded, wait and retry
|
||||||
|
|
||||||
|
**"Invalid API Key"** → Copy key exactly (no spaces), verify it's active on service website
|
||||||
|
|
||||||
|
**"ScreenScraper authentication failed"** → Check donation level and account status
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
215
docs/02-tecnico/frontend.md
Normal file
215
docs/02-tecnico/frontend.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Frontend - Landing Page de Quasar
|
||||||
|
|
||||||
|
## Visión General
|
||||||
|
|
||||||
|
El frontend de Quasar está implementado con **Next.js 16.1.6**, **React 19**, **TypeScript**, **Shadcn UI** y **Tailwind CSS 4**. La landing page presenta una estética **Mass Effect-inspired** con efectos de glassmorphism, holográficos y una paleta de colores cyberpunk cyan y gold sobre fondo oscuro espacial.
|
||||||
|
|
||||||
|
## Stack Tecnológico
|
||||||
|
|
||||||
|
| Tecnología | Versión | Propósito |
|
||||||
|
| ------------ | ------- | ------------------------------ |
|
||||||
|
| Next.js | 16.1.6 | Framework React con App Router |
|
||||||
|
| React | 19.2.3 | Biblioteca UI |
|
||||||
|
| TypeScript | 5.x | Type safety |
|
||||||
|
| Shadcn UI | 3.8.5 | Componentes accesibles |
|
||||||
|
| Tailwind CSS | 4.x | Estilos utility-first |
|
||||||
|
| Yarn | 4.12.0 | Gestor de paquetes |
|
||||||
|
|
||||||
|
## Estética Visual
|
||||||
|
|
||||||
|
### Paleta de Colores Mass Effect-inspired
|
||||||
|
|
||||||
|
| Color | Hex | Uso |
|
||||||
|
| -------------- | --------- | -------------------------------- |
|
||||||
|
| Background | `#0a0a12` | Fondo oscuro espacial |
|
||||||
|
| Primary (Cyan) | `#00d0e0` | Acentos principales, botones |
|
||||||
|
| Accent (Gold) | `#f0c040` | Detalles secundarios, highlights |
|
||||||
|
| Text | `#ffffff` | Texto principal |
|
||||||
|
| Muted | `#64748b` | Texto secundario |
|
||||||
|
|
||||||
|
### Efectos Visuales Implementados
|
||||||
|
|
||||||
|
- **Glassmorphism:** `backdrop-filter: blur(10px)` en navbar y footer
|
||||||
|
- **Glowing effects:** Brillo cyan y gold en elementos interactivos
|
||||||
|
- **Holographic:** Animación de escaneo horizontal en bordes
|
||||||
|
- **Pulse animation:** Indicadores de estado con pulso
|
||||||
|
- **Starfield background:** Fondo animado de estrellas
|
||||||
|
|
||||||
|
## Componentes de la Landing Page
|
||||||
|
|
||||||
|
### Navbar
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/Navbar.tsx`](../frontend/src/components/landing/Navbar.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Fijo en la parte superior con glassmorphism
|
||||||
|
- Logo "QUASAR" con efecto glow cyan
|
||||||
|
- Barra de búsqueda con efecto de brillo al enfocar
|
||||||
|
- Responsive con menú móvil desplegable
|
||||||
|
- **Accesibilidad:** `aria-label`, `aria-expanded`, `tabIndex` dinámico
|
||||||
|
|
||||||
|
### Hero Section
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/Hero.tsx`](../frontend/src/components/landing/Hero.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Imagen de fondo espacial de alta calidad (Unsplash)
|
||||||
|
- Título "FEATURED MISSION" y nombre del juego "Stellar Odyssey"
|
||||||
|
- Efecto holográfico en el borde
|
||||||
|
- Botón CTA "MISSION START" con gradiente cyan-gold
|
||||||
|
- Estadísticas del juego (rating, horas, gráficos)
|
||||||
|
- **Accesibilidad:** `id="hero"`, `aria-labelledby`, `alt` descriptivo
|
||||||
|
|
||||||
|
### Game Grid
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/GameGrid.tsx`](../frontend/src/components/landing/GameGrid.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Grid de tarjetas de juegos con diseño responsive
|
||||||
|
- Estadísticas reveladas al hover (rating, género, año, plataforma)
|
||||||
|
- Efectos hover con transformación y brillo
|
||||||
|
- **Accesibilidad:** `id="games"`, `aria-labelledby`, `loading="lazy"`, `aria-hidden`
|
||||||
|
|
||||||
|
### Footer
|
||||||
|
|
||||||
|
- **Ubicación:** [`frontend/src/components/landing/Footer.tsx`](../frontend/src/components/landing/Footer.tsx)
|
||||||
|
- **Características:**
|
||||||
|
- Diseño minimalista con glassmorphism
|
||||||
|
- Indicador "SYSTEM STATUS: ONLINE" con animación de pulso
|
||||||
|
- Enlaces de navegación secundarios
|
||||||
|
- **Accesibilidad:** `role="contentinfo"`
|
||||||
|
|
||||||
|
## Configuración del Tema
|
||||||
|
|
||||||
|
### globals.css
|
||||||
|
|
||||||
|
El tema Mass Effect-inspired se configura en [`frontend/src/app/globals.css`](../frontend/src/app/globals.css):
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--background: 240 10% 4%; /* #0a0a12 */
|
||||||
|
--primary: 180 100% 44%; /* #00d0e0 */
|
||||||
|
--accent: 45 90% 60%; /* #f0c040 */
|
||||||
|
/* ... más variables */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animaciones Personalizadas
|
||||||
|
|
||||||
|
- `.glass` - Efecto de vidrio esmerilado
|
||||||
|
- `.glow-cyan`, `.glow-gold` - Efectos de brillo
|
||||||
|
- `.holographic` - Efecto holográfico con escaneo
|
||||||
|
- `.pulse` - Animación de pulso
|
||||||
|
- `.starfield` - Fondo animado de estrellas
|
||||||
|
|
||||||
|
## Accesibilidad y Compliance
|
||||||
|
|
||||||
|
### Web Interface Guidelines Compliance
|
||||||
|
|
||||||
|
| Categoría | Cumplimiento | Detalles |
|
||||||
|
| ----------------- | --------------- | ------------------------------------------------------- |
|
||||||
|
| Accesibilidad | ✅ 95%+ | ARIA labels, keyboard navigation, screen reader support |
|
||||||
|
| Semántica HTML5 | ✅ 95%+ | `id` en secciones, `role` en footer, `main` con id |
|
||||||
|
| Contrast Ratios | ✅ WCAG AA | Cyan `#00d0e0`, Gold `#f0c040` |
|
||||||
|
| Responsive Design | ✅ Mobile-first | `min-h-screen`, breakpoints `sm:`, `md:`, `lg:` |
|
||||||
|
| Performance | ✅ Optimizado | Lazy loading, imágenes optimizadas |
|
||||||
|
| SEO | ✅ Optimizado | Metadata específica, OpenGraph tags, `lang="es"` |
|
||||||
|
|
||||||
|
### Mejoras Implementadas
|
||||||
|
|
||||||
|
- **Accesibilidad:** Labels ARIA, `aria-expanded`, `tabIndex` dinámico, `alt` descriptivos
|
||||||
|
- **Semántica HTML5:** `id` en secciones, `role` en footer, `main` con id
|
||||||
|
- **Contrast Ratios:** Cyan ajustado a `#00d0e0`, Gold ajustado a `#f0c040`
|
||||||
|
- **Responsive Design:** `min-h-screen` en lugar de `h-screen`, `pt-16` en main
|
||||||
|
- **Performance:** `loading="lazy"` en imágenes del grid, `priority` en Hero
|
||||||
|
- **SEO:** Metadata específica de Quasar, `lang="es"`, OpenGraph tags
|
||||||
|
|
||||||
|
## Desarrollo Local
|
||||||
|
|
||||||
|
### Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Desarrollo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev
|
||||||
|
# Frontend disponible en: http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build para Producción
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
yarn type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── favicon.ico
|
||||||
|
│ │ ├── globals.css # Tema Mass Effect + animaciones
|
||||||
|
│ │ ├── layout.tsx # Root layout con metadata SEO
|
||||||
|
│ │ └── page.tsx # Landing page con componentes
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── landing/
|
||||||
|
│ │ │ ├── Navbar.tsx # Navbar con glassmorphism
|
||||||
|
│ │ │ ├── Hero.tsx # Hero section con featured game
|
||||||
|
│ │ │ ├── GameGrid.tsx # Grid de tarjetas con hover effects
|
||||||
|
│ │ │ └── Footer.tsx # Footer minimalista
|
||||||
|
│ │ └── ui/
|
||||||
|
│ │ ├── button.tsx # Componente Shadcn UI
|
||||||
|
│ │ ├── card.tsx # Componente Shadcn UI
|
||||||
|
│ │ └── input.tsx # Componente Shadcn UI
|
||||||
|
│ └── lib/
|
||||||
|
│ └── utils.ts # Utilidades de Shadcn UI
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── tailwind.config.ts
|
||||||
|
└── next.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Componentes Shadcn UI Instalados
|
||||||
|
|
||||||
|
- **Button:** Botones con variantes (default, destructive, outline, secondary, ghost, link)
|
||||||
|
- **Input:** Campos de entrada con estilos consistentes
|
||||||
|
- **Card:** Tarjetas con header, content y footer
|
||||||
|
|
||||||
|
## Imágenes Placeholder
|
||||||
|
|
||||||
|
Todas las imágenes utilizadas son de alta calidad de Unsplash:
|
||||||
|
|
||||||
|
- **Hero background:** Imagen espacial/sci-fi
|
||||||
|
- **Game covers:** Imágenes de videojuegos variados
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
- [ ] Integrar con backend API para datos reales de juegos
|
||||||
|
- [ ] Añadir páginas adicionales (Dashboard, Games Library, Settings)
|
||||||
|
- [ ] Implementar autenticación de usuarios
|
||||||
|
- [ ] Añadir tests unitarios y E2E para componentes
|
||||||
|
- [ ] Implementar internacionalización (i18n)
|
||||||
|
|
||||||
|
## Referencias
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs)
|
||||||
|
- [Shadcn UI Documentation](https://ui.shadcn.com)
|
||||||
|
- [Tailwind CSS Documentation](https://tailwindcss.com/docs)
|
||||||
|
- [Web Interface Guidelines](https://vercel-labs.github.io/web-interface-guidelines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-23_
|
||||||
546
docs/04-operaciones/deployment.md
Normal file
546
docs/04-operaciones/deployment.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# Guía de Despliegue y Operaciones 🚀
|
||||||
|
|
||||||
|
Esta guía cubre el despliegue, configuración y operación de Quasar en producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tabla de Contenidos
|
||||||
|
|
||||||
|
1. [Requisitos del Sistema](#requisitos-del-sistema)
|
||||||
|
2. [Configuración de Producción](#configuración-de-producción)
|
||||||
|
3. [Despliegue](#despliegue)
|
||||||
|
4. [Monitoreo y Mantenimiento](#monitoreo-y-mantenimiento)
|
||||||
|
5. [Actualizaciones](#actualizaciones)
|
||||||
|
6. [Backup y Recuperación](#backup-y-recuperación)
|
||||||
|
7. [Solución de Problemas](#solución-de-problemas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requisitos del Sistema
|
||||||
|
|
||||||
|
### Hardware Mínimo
|
||||||
|
|
||||||
|
- **CPU:** 2 cores
|
||||||
|
- **RAM:** 4GB
|
||||||
|
- **Almacenamiento:** 20GB (para ROMs y metadata)
|
||||||
|
- **Red:** Estable (para descargas de artwork)
|
||||||
|
|
||||||
|
### Software
|
||||||
|
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Yarn 4.x**
|
||||||
|
- **SQLite** (o PostgreSQL para producción)
|
||||||
|
- **Nginx** (recomendado para reverse proxy)
|
||||||
|
- **Certificado SSL** (HTTPS obligatorio)
|
||||||
|
|
||||||
|
### Dependencias Externas
|
||||||
|
|
||||||
|
- Claves API de IGDB, RAWG, TheGamesDB
|
||||||
|
- Acceso a servicios de descarga de imágenes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuración de Producción
|
||||||
|
|
||||||
|
### Variables de Entorno
|
||||||
|
|
||||||
|
Crear `.env.production` con:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DATABASE_URL="file:./production.db"
|
||||||
|
# Para PostgreSQL: postgresql://user:password@localhost:5432/quasar
|
||||||
|
|
||||||
|
# API Keys
|
||||||
|
IGDB_CLIENT_ID=your_production_client_id
|
||||||
|
IGDB_CLIENT_SECRET=your_production_client_secret
|
||||||
|
RAWG_API_KEY=your_production_api_key
|
||||||
|
THEGAMESDB_API_KEY=your_production_api_key
|
||||||
|
SCREENSCRAPER_USERNAME=your_screenscraper_username
|
||||||
|
SCREENSCRAPER_PASSWORD=your_screenscraper_password
|
||||||
|
|
||||||
|
# App Config
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Security
|
||||||
|
CORS_ORIGIN=https://yourdomain.com
|
||||||
|
JWT_SECRET=your_secure_jwt_secret_here
|
||||||
|
API_RATE_LIMIT=100
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
CACHE_TTL=86400
|
||||||
|
MAX_CONCURRENT_API_REQUESTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuración de Nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name yourdomain.com;
|
||||||
|
|
||||||
|
# SSL Configuration
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options DENY;
|
||||||
|
add_header X-Content-Type-Options nosniff;
|
||||||
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
root /var/www/quasar/frontend/dist;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_read_timeout 864s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location /static/ {
|
||||||
|
root /var/www/quasar;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
### Opción 1: Docker (Recomendado)
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Dockerfile
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
quasar-backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=file:./production.db
|
||||||
|
- IGDB_CLIENT_ID=${IGDB_CLIENT_ID}
|
||||||
|
- IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET}
|
||||||
|
- RAWG_API_KEY=${RAWG_API_KEY}
|
||||||
|
- THEGAMESDB_API_KEY=${THEGAMESDB_API_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backend/prisma:/app/prisma
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
quasar-frontend:
|
||||||
|
build: ./frontend
|
||||||
|
ports:
|
||||||
|
- '5173:5173'
|
||||||
|
depends_on:
|
||||||
|
- quasar-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
- ./ssl:/etc/nginx/ssl
|
||||||
|
depends_on:
|
||||||
|
- quasar-backend
|
||||||
|
- quasar-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Opción 2: VPS Manual
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Setup server
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nodejs yarn nginx sqlite3
|
||||||
|
|
||||||
|
# 2. Clone repository
|
||||||
|
git clone https://your-repo/quasar.git
|
||||||
|
cd quasar
|
||||||
|
|
||||||
|
# 3. Install dependencies
|
||||||
|
yarn install --production
|
||||||
|
|
||||||
|
# 4. Setup environment
|
||||||
|
cp .env.example .env.production
|
||||||
|
# Edit .env.production with real values
|
||||||
|
|
||||||
|
# 5. Build frontend
|
||||||
|
cd frontend
|
||||||
|
yarn build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 6. Setup database
|
||||||
|
cd backend
|
||||||
|
npx prisma migrate deploy
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 7. Configure nginx
|
||||||
|
sudo cp nginx.conf /etc/nginx/sites-available/quasar
|
||||||
|
sudo ln -s /etc/nginx/sites-available/quasar /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# 8. Start services
|
||||||
|
cd backend
|
||||||
|
nohup yarn start > /var/log/quasar-backend.log 2>&1 &
|
||||||
|
cd ../frontend
|
||||||
|
nohup yarn start > /var/log/quasar-frontend.log 2>&1 &
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoreo y Mantenimiento
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend health
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
curl http://localhost:3000/api/health/database
|
||||||
|
|
||||||
|
# API rate limits status
|
||||||
|
curl http://localhost:3000/api/health/rate-limits
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
Configurar logrotate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/logrotate.d/quasar
|
||||||
|
/var/log/quasar/*.log {
|
||||||
|
daily
|
||||||
|
missingok
|
||||||
|
rotate 7
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoreo de API Keys
|
||||||
|
|
||||||
|
Crear script para verificar límites:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# check-api-limits.sh
|
||||||
|
|
||||||
|
# Check IGDB rate limits
|
||||||
|
curl -s -I "https://api.igdb.com/v4/games" | grep -i "x-ratelimit"
|
||||||
|
|
||||||
|
# Check RAWG usage
|
||||||
|
curl -s "https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=test" | jq '.count'
|
||||||
|
|
||||||
|
# Log warnings
|
||||||
|
echo "$(date): API rate limits checked" >> /var/log/quasar/api-monitor.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actualizaciones
|
||||||
|
|
||||||
|
### Proceso de Actualización
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Backup
|
||||||
|
./backup.sh
|
||||||
|
|
||||||
|
# 2. Stop services
|
||||||
|
sudo systemctl stop quasar-backend
|
||||||
|
sudo systemctl stop quasar-frontend
|
||||||
|
|
||||||
|
# 3. Pull latest code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 4. Update dependencies
|
||||||
|
yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
# 5. Build frontend
|
||||||
|
cd frontend && yarn build && cd ..
|
||||||
|
|
||||||
|
# 6. Run migrations
|
||||||
|
cd backend && npx prisma migrate deploy && cd ..
|
||||||
|
|
||||||
|
# 7. Start services
|
||||||
|
sudo systemctl start quasar-backend
|
||||||
|
sudo systemctl start quasar-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actualizaciones de API Keys
|
||||||
|
|
||||||
|
1. Generar nuevas claves en cada servicio
|
||||||
|
2. Actualizar variables de entorno
|
||||||
|
3. Reiniciar servicios
|
||||||
|
4. Monitorear errores durante 24h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup y Recuperación
|
||||||
|
|
||||||
|
### Script de Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup.sh
|
||||||
|
|
||||||
|
BACKUP_DIR="/backups/quasar"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
DB_FILE="quasar_$DATE.db"
|
||||||
|
ROMS_DIR="roms_$DATE"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
cp backend/prisma/production.db "$BACKUP_DIR/$DB_FILE"
|
||||||
|
|
||||||
|
# Backup ROM metadata (not actual ROMs)
|
||||||
|
cp -r data/roms_metadata "$BACKUP_DIR/$ROMS_DIR"
|
||||||
|
|
||||||
|
# Backup configuration
|
||||||
|
cp .env.production "$BACKUP_DIR/env_$DATE"
|
||||||
|
|
||||||
|
# Compress backup
|
||||||
|
tar -czf "$BACKUP_DIR/backup_$DATE.tar.gz" -C "$BACKUP_DIR" "$DB_FILE" "$ROMS_DIR" "env_$DATE"
|
||||||
|
|
||||||
|
# Clean up old backups (keep last 7 days)
|
||||||
|
find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime +7 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR/backup_$DATE.tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recuperación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# restore.sh
|
||||||
|
|
||||||
|
BACKUP_FILE=$1
|
||||||
|
BACKUP_DIR="/backups/quasar"
|
||||||
|
|
||||||
|
if [ ! -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
|
||||||
|
echo "Backup file not found: $BACKUP_DIR/$BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
sudo systemctl stop quasar-backend
|
||||||
|
sudo systemctl stop quasar-frontend
|
||||||
|
|
||||||
|
# Extract backup
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
tar -xzf "$BACKUP_FILE"
|
||||||
|
|
||||||
|
# Restore database
|
||||||
|
cp "$DB_FILE" backend/prisma/production.db
|
||||||
|
|
||||||
|
# Restore ROM metadata
|
||||||
|
cp -r "$ROMS_DIR"/* data/
|
||||||
|
|
||||||
|
# Restore configuration (optional)
|
||||||
|
# cp "env_$DATE" .env.production
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
sudo systemctl start quasar-backend
|
||||||
|
sudo systemctl start quasar-frontend
|
||||||
|
|
||||||
|
echo "Restore completed from: $BACKUP_FILE"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solución de Problemas
|
||||||
|
|
||||||
|
### Problemas Comunes
|
||||||
|
|
||||||
|
#### 1. "Database connection failed"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database file
|
||||||
|
ls -la backend/prisma/production.db
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
sudo chown -R nodejs:nodejs backend/prisma/
|
||||||
|
|
||||||
|
# Check database integrity
|
||||||
|
sqlite3 backend/prisma/production.db "PRAGMA integrity_check;"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. "API rate limit exceeded"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current rate limits
|
||||||
|
curl -I "https://api.igdb.com/v4/games" | grep -i "x-ratelimit"
|
||||||
|
|
||||||
|
# Implement backoff strategy
|
||||||
|
# Check logs for specific API errors
|
||||||
|
tail -f /var/log/quasar/backend.log | grep "429"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. "Frontend cannot connect to backend"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend is running
|
||||||
|
curl http://localhost:3000/health
|
||||||
|
|
||||||
|
# Check CORS configuration
|
||||||
|
curl -H "Origin: https://yourdomain.com" -v http://localhost:3000/health
|
||||||
|
|
||||||
|
# Check nginx configuration
|
||||||
|
sudo nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. "ROM scanning fails"
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check directory permissions
|
||||||
|
ls -la /path/to/roms/
|
||||||
|
|
||||||
|
# Check file formats
|
||||||
|
find /path/to/roms/ -name "*.zip" -o -name "*.7z" -o -name "*.rar"
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
df -h
|
||||||
|
```
|
||||||
|
|
||||||
|
### Diagnóstico Remoto
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create diagnostic script
|
||||||
|
#!/bin/bash
|
||||||
|
# diagnostic.sh
|
||||||
|
|
||||||
|
echo "=== Quasar Diagnostic Report ==="
|
||||||
|
echo "Date: $(date)"
|
||||||
|
echo "Node.js version: $(node --version)"
|
||||||
|
echo "Yarn version: $(yarn --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== System Resources ==="
|
||||||
|
free -h
|
||||||
|
df -h
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Services Status ==="
|
||||||
|
systemctl status quasar-backend
|
||||||
|
systemctl status quasar-frontend
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Database Status ==="
|
||||||
|
sqlite3 backend/prisma/production.db "SELECT COUNT(*) FROM games;"
|
||||||
|
sqlite3 backend/prisma/production.db "SELECT COUNT(*) FROM rom_files;"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== API Keys Status ==="
|
||||||
|
echo "IGDB: ${IGDB_CLIENT_ID:0:10}..."
|
||||||
|
echo "RAWG: ${RAWG_API_KEY:0:10}..."
|
||||||
|
echo "TheGamesDB: ${THEGAMESDB_API_KEY:0:10}..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=== Recent Errors ==="
|
||||||
|
tail -20 /var/log/quasar/backend.log | grep -i "error"
|
||||||
|
tail -20 /var/log/quasar/frontend.log | grep -i "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
### Logs de Depuración
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend logs
|
||||||
|
tail -f /var/log/quasar/backend.log
|
||||||
|
|
||||||
|
# Frontend logs
|
||||||
|
tail -f /var/log/quasar/frontend.log
|
||||||
|
|
||||||
|
# Nginx logs
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contacto
|
||||||
|
|
||||||
|
- **Issues:** Reportar en el repositorio de Gitea
|
||||||
|
- **Emergencias:** Email: support@yourdomain.com
|
||||||
|
- **Documentación:** Ver [docs/README.md](../../README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-22_
|
||||||
93
docs/README.md
Normal file
93
docs/README.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Documentación del Proyecto Quasar 📚
|
||||||
|
|
||||||
|
Esta documentación está organizada en secciones lógicas para facilitar la navegación y mantenimiento.
|
||||||
|
|
||||||
|
## Estructura de la Documentación
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/
|
||||||
|
├── README.md # Este archivo (índice general)
|
||||||
|
├── 01-conceptos/ # Conceptos fundamentales y requisitos
|
||||||
|
│ ├── requirements.md # Requisitos funcionales y no funcionales
|
||||||
|
│ ├── architecture.md # Arquitectura técnica general
|
||||||
|
│ └── data-model.md # Modelo de datos y esquema
|
||||||
|
├── 02-tecnico/ # Documentación técnica detallada
|
||||||
|
│ ├── apis.md # APIs del sistema (consolidado)
|
||||||
|
│ ├── frontend.md # Documentación del frontend
|
||||||
|
│ └── lessons-learned.md # Lecciones aprendidas y recomendaciones
|
||||||
|
├── 03-analisis/ # Análisis comparativos y estudios
|
||||||
|
│ └── competitive-analysis.md # Análisis competitivo
|
||||||
|
└── 04-operaciones/ # Guías de operación y despliegue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Guía de Navegación
|
||||||
|
|
||||||
|
### 🎯 Para nuevos desarrolladores
|
||||||
|
|
||||||
|
1. Comienza con [`01-conceptos/requirements.md`](01-conceptos/requirements.md) para entender el propósito
|
||||||
|
2. Lee [`01-conceptos/architecture.md`](01-conceptos/architecture.md) para la visión general
|
||||||
|
3. Revisa [`01-conceptos/data-model.md`](01-conceptos/data-model.md) para entender los datos
|
||||||
|
|
||||||
|
### 🔧 Para trabajo técnico
|
||||||
|
|
||||||
|
1. Consulta [`02-tecnico/apis.md`](02-tecnico/apis.md) para APIs y configuración
|
||||||
|
2. Revisa [`02-tecnico/frontend.md`](02-tecnico/frontend.md) para detalles del frontend
|
||||||
|
3. Lee [`02-tecnico/lessons-learned.md`](02-tecnico/lessons-learned.md) para buenas prácticas
|
||||||
|
|
||||||
|
### 📊 Para análisis y decisiones
|
||||||
|
|
||||||
|
1. Revisa [`03-analisis/competitive-analysis.md`](03-analisis/competitive-analysis.md) para contexto del mercado
|
||||||
|
|
||||||
|
### 🚀 Para operaciones y despliegue
|
||||||
|
|
||||||
|
1. Las guías de operación están en desarrollo (sección `04-operaciones/`)
|
||||||
|
|
||||||
|
## Convenciones
|
||||||
|
|
||||||
|
### Formato de enlaces
|
||||||
|
|
||||||
|
Todos los enlaces internos usan formato markdown estándar:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[texto de enlace](ruta/al/archivo.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nomenclatura de archivos
|
||||||
|
|
||||||
|
- Todos los usan `kebab-case.md`
|
||||||
|
- Los prefijos numéricos indican orden de lectura
|
||||||
|
|
||||||
|
### Estructura de documentos
|
||||||
|
|
||||||
|
- Cada documento tiene tabla de contenidos (TOC)
|
||||||
|
- Secciones numeradas para mejor navegación
|
||||||
|
- Ejemplos de código con formato sintáctico
|
||||||
|
|
||||||
|
## Estado Actual
|
||||||
|
|
||||||
|
| Sección | Estado | Comentarios |
|
||||||
|
| -------------- | ---------------- | ----------------------------------------------------- |
|
||||||
|
| 01-conceptos | ✅ Completa | Documentación fundamental estable |
|
||||||
|
| 02-tecnico | ✅ Actualizada | APIs consolidados, frontend completo con landing page |
|
||||||
|
| 03-analisis | ✅ Completa | Análisis competitivo actualizado |
|
||||||
|
| 04-operaciones | 🚧 En desarrollo | Guías de operación pendientes |
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
- [x] Documentar API REST detallada
|
||||||
|
- [x] Documentar frontend con landing page
|
||||||
|
- [ ] Añadir documentación de testing y CI/CD
|
||||||
|
- [ ] Crear índice temático para búsqueda rápida
|
||||||
|
|
||||||
|
## Contribuir
|
||||||
|
|
||||||
|
Al agregar nuevo contenido:
|
||||||
|
|
||||||
|
1. Coloca el documento en la sección adecuada
|
||||||
|
2. Sigue las convenciones de nomenclatura
|
||||||
|
3. Actualiza este README si agregas nuevas secciones
|
||||||
|
4. Revisa y actualiza referencias cruzadas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Última actualización: 2026-02-23_
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Integración de APIs externas — Prioridad y guía práctica
|
|
||||||
|
|
||||||
## Objetivo
|
|
||||||
|
|
||||||
Definir APIs prioritarias para el MVP, cómo obtener credenciales, ejemplos de uso y estrategias de robustez (rate limit, retries, fallback y normalización de datos).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## APIs priorizadas (MVP)
|
|
||||||
|
|
||||||
1. **IGDB (prioridad alta)**
|
|
||||||
2. **RAWG (prioridad alta)**
|
|
||||||
3. **TheGamesDB (prioridad media)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## IGDB
|
|
||||||
|
|
||||||
- **Obtener credenciales**: registrar una app en Twitch Developer Console para obtener `CLIENT_ID` y `CLIENT_SECRET`. Obtener token con grant type `client_credentials` (POST a `https://id.twitch.tv/oauth2/token`).
|
|
||||||
|
|
||||||
- **Endpoints principales**: `POST https://api.igdb.com/v4/games` (consulta flexible via body con sintaxis IGDB), `POST https://api.igdb.com/v4/covers`, `POST https://api.igdb.com/v4/platforms`.
|
|
||||||
|
|
||||||
- **Ejemplo (buscar)**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Obtener token
|
|
||||||
curl -X POST 'https://id.twitch.tv/oauth2/token?client_id=$IGDB_CLIENT_ID&client_secret=$IGDB_CLIENT_SECRET&grant_type=client_credentials'
|
|
||||||
|
|
||||||
# Buscar juegos
|
|
||||||
curl -X POST 'https://api.igdb.com/v4/games' \
|
|
||||||
-H "Client-ID: $IGDB_CLIENT_ID" \
|
|
||||||
-H "Authorization: Bearer $IGDB_TOKEN" \
|
|
||||||
-H 'Accept: application/json' \
|
|
||||||
--data 'fields id,name,first_release_date,platforms.name,genres.name,cover.url; search "zelda"; limit 5;'
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Respuesta (esquemática)**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"id": 12345,
|
|
||||||
"name": "Ejemplo",
|
|
||||||
"first_release_date": 1459468800,
|
|
||||||
"platforms": [{ "name": "Nintendo Switch" }],
|
|
||||||
"cover": { "url": "//images.igdb.com/...jpg" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Límites y manejo**: la API puede devolver `429` o cabeceras de límite; implementar retries exponenciales (ej. 3 intentos) y respetar `Retry-After`. Implementar circuit breaker si la API falla repetidamente.
|
|
||||||
- **Atribución**: mostrar origen de datos (ej. "Datos: IGDB") según términos del servicio.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## RAWG
|
|
||||||
|
|
||||||
- **Obtener credenciales**: registrarse en RAWG para obtener `RAWG_API_KEY` (https://rawg.io/apidocs).
|
|
||||||
- **Endpoints principales**: `GET https://api.rawg.io/api/games?key=API_KEY&search=...`, `GET https://api.rawg.io/api/games/{id}`.
|
|
||||||
- **Ejemplo**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl 'https://api.rawg.io/api/games?key=$RAWG_API_KEY&search=zelda&page_size=5'
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Respuesta (esquemática)**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"count": 100,
|
|
||||||
"results": [
|
|
||||||
{ "id": 3498, "name": "GTA V", "released": "2013-09-17", "background_image": "https://..." }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Límites y manejo**: RAWG suele tener límites por clave/plan; cachear y fallback a otros proveedores si falla.
|
|
||||||
- **Atribución**: revisar condiciones y mostrar HTTP o texto de fuente si es requerido por el proveedor.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TheGamesDB
|
|
||||||
|
|
||||||
- **Obtener credenciales**: crear cuenta y generar API Key en https://thegamesdb.net.
|
|
||||||
- **Endpoints**: búsqueda por nombre y detalles (`/v1/Games/ByGameName?name=...`, `/v1/Games/ByGameID?id=...`).
|
|
||||||
- **Ejemplo**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -H 'Authorization: Bearer $THEGAMESDB_KEY' 'https://api.thegamesdb.net/v1/Games/ByGameName?name=zelda'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Estrategia de fallback y normalización
|
|
||||||
|
|
||||||
- **Orden de prioridad**: IGDB → RAWG → TheGamesDB (configurable).
|
|
||||||
- **Normalización (mapping)**:
|
|
||||||
- `title` ← `name`
|
|
||||||
- `platform` ← `platforms[].name`
|
|
||||||
- `release_date` ← `first_release_date` / `released` → convertir a ISO 8601
|
|
||||||
- `genres` ← `genres[].name`
|
|
||||||
- `cover_url` ← `cover.url` / `background_image`
|
|
||||||
- `external_ids` ← `{ igdb: id, rawg: id, thegamesdb: id }`
|
|
||||||
|
|
||||||
- **Fallback**: si IGDB no tiene portada, intentar RAWG; si falla, usar TheGamesDB. Registrar la fuente usada.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Caché y almacenamiento de artwork
|
|
||||||
|
|
||||||
- **Caché metadata**: LRU en memoria o Redis con TTL (por ejemplo 24h) para evitar sobrecargar APIs.
|
|
||||||
- **Almacenamiento de imágenes**: descargar y optimizar con `sharp` (crear versiones: thumb, medium), almacenar en `storage/artwork/{gameId}/cover.jpg` o S3.
|
|
||||||
- **Servicio proxy**: servir imágenes desde backend para no exponer keys ni URLs externas.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manejo de errores y resiliencia
|
|
||||||
|
|
||||||
- Implementar **retries** exponenciales con jitter (3 intentos).
|
|
||||||
- Implementar **circuit breaker** para desconectar llamadas a un proveedor fuera de servicio por N minutos.
|
|
||||||
- Limitar concurrencia por proveedor (p. ej. 5 llamadas simultáneas) y usar colas para trabajos masivos (enriquecimiento masivo).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Variables de entorno (ejemplos)
|
|
||||||
|
|
||||||
```
|
|
||||||
IGDB_CLIENT_ID=...
|
|
||||||
IGDB_CLIENT_SECRET=...
|
|
||||||
RAWG_API_KEY=...
|
|
||||||
THEGAMESDB_API_KEY=...
|
|
||||||
EXTERNAL_API_CONCURRENCY=5
|
|
||||||
```
|
|
||||||
|
|
||||||
> Nota: **Nunca** exponer estas claves en el cliente; siempre pasar por el backend.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fuentes
|
|
||||||
|
|
||||||
- IGDB API docs, RAWG API docs, TheGamesDB API docs.
|
|
||||||
- Patrones: retries, circuit breakers (ej. libraries: `p-retry`, `cockatiel`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Metadatos**
|
|
||||||
Autor: Quasar (investigación automatizada)
|
|
||||||
Última actualización: 2026-02-07
|
|
||||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
23
frontend/components.json
Normal file
23
frontend/components.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
16
frontend/next.config.ts
Normal file
16
frontend/next.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'images.unsplash.com',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
33
frontend/package.json
Normal file
33
frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
|
"shadcn": "^3.8.5",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
297
frontend/src/app/globals.css
Normal file
297
frontend/src/app/globals.css
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: #0a0a12;
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.11 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.11 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: #00d0e0;
|
||||||
|
--primary-foreground: #0a0a12;
|
||||||
|
--secondary: oklch(0.18 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.18 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: #f0c040;
|
||||||
|
--accent-foreground: #0a0a12;
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: #00f0ff;
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.11 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: #00f0ff;
|
||||||
|
--sidebar-primary-foreground: #0a0a12;
|
||||||
|
--sidebar-accent: oklch(0.18 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: #00f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: #0a0a12;
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.11 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.11 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: #00d0e0;
|
||||||
|
--primary-foreground: #0a0a12;
|
||||||
|
--secondary: oklch(0.18 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.18 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: #f0c040;
|
||||||
|
--accent-foreground: #0a0a12;
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: #00f0ff;
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.11 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: #00f0ff;
|
||||||
|
--sidebar-primary-foreground: #0a0a12;
|
||||||
|
--sidebar-accent: oklch(0.18 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: #00f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mass Effect-inspired theme customizations */
|
||||||
|
:root {
|
||||||
|
/* Custom colors for Mass Effect theme */
|
||||||
|
--mass-effect-dark: #0a0a12;
|
||||||
|
--mass-effect-cyan: #00d0e0;
|
||||||
|
--mass-effect-gold: #f0c040;
|
||||||
|
--mass-effect-cyan-glow: rgba(0, 208, 224, 0.5);
|
||||||
|
--mass-effect-gold-glow: rgba(240, 192, 64, 0.5);
|
||||||
|
--glass-bg: rgba(10, 10, 18, 0.7);
|
||||||
|
--glass-border: rgba(0, 208, 224, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism effect */
|
||||||
|
.glass {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-cyan {
|
||||||
|
box-shadow: 0 0 10px var(--mass-effect-cyan-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-cyan-intense {
|
||||||
|
box-shadow: 0 0 20px var(--mass-effect-cyan-glow), 0 0 40px var(--mass-effect-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-gold {
|
||||||
|
box-shadow: 0 0 10px var(--mass-effect-gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text effects */
|
||||||
|
.text-glow-cyan {
|
||||||
|
text-shadow: 0 0 10px var(--mass-effect-cyan-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-glow-gold {
|
||||||
|
text-shadow: 0 0 10px var(--mass-effect-gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Holographic effect */
|
||||||
|
.holographic {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holographic::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(0, 240, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: holographic-scan 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes holographic-scan {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for system status */
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse {
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover glow effect */
|
||||||
|
.hover-glow:hover {
|
||||||
|
box-shadow: 0 0 15px var(--mass-effect-cyan-glow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Starfield background */
|
||||||
|
.starfield {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: -1;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(2px 2px at 20px 30px, #eee, transparent),
|
||||||
|
radial-gradient(2px 2px at 40px 70px, #eee, transparent),
|
||||||
|
radial-gradient(1px 1px at 50px 50px, #eee, transparent),
|
||||||
|
radial-gradient(1px 1px at 80px 10px, #eee, transparent),
|
||||||
|
radial-gradient(2px 2px at 130px 80px, #eee, transparent);
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 200px 200px;
|
||||||
|
opacity: 0.3;
|
||||||
|
animation: starfield-move 120s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes starfield-move {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-200px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom button styles */
|
||||||
|
.btn-mission {
|
||||||
|
background: linear-gradient(45deg, var(--mass-effect-cyan), var(--mass-effect-gold));
|
||||||
|
border: none;
|
||||||
|
color: var(--mass-effect-dark);
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mission:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 20px var(--mass-effect-cyan-glow), 0 0 40px var(--mass-effect-gold-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mission::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transition: left 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mission:hover::before {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search bar glow effect */
|
||||||
|
.search-glow:focus {
|
||||||
|
box-shadow: 0 0 0 1px var(--mass-effect-cyan), 0 0 15px var(--mass-effect-cyan-glow);
|
||||||
|
border-color: var(--mass-effect-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
frontend/src/app/layout.tsx
Normal file
38
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
subsets: ['latin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Quasar - Tu Biblioteca de Videojuegos',
|
||||||
|
description:
|
||||||
|
'Gestiona tu colección de videojuegos con Quasar. Organiza, escanea y explora tu biblioteca personal.',
|
||||||
|
keywords: ['videojuegos', 'emulador', 'retro gaming', 'video game library'],
|
||||||
|
openGraph: {
|
||||||
|
title: 'Quasar - Tu Biblioteca de Videojuegos',
|
||||||
|
description:
|
||||||
|
'Gestiona tu colección de videojuegos con Quasar. Organiza, escanea y explora tu biblioteca personal.',
|
||||||
|
type: 'website',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="es" suppressHydrationWarning>
|
||||||
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/app/page.tsx
Normal file
28
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Navbar from '@/components/landing/Navbar';
|
||||||
|
import Hero from '@/components/landing/Hero';
|
||||||
|
import GameGrid from '@/components/landing/GameGrid';
|
||||||
|
import Footer from '@/components/landing/Footer';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--mass-effect-dark)' }}>
|
||||||
|
{/* Starfield Background */}
|
||||||
|
<div className="starfield"></div>
|
||||||
|
|
||||||
|
{/* Navbar */}
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main id="main-content" className="pt-16">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<Hero />
|
||||||
|
|
||||||
|
{/* Game Grid Section */}
|
||||||
|
<GameGrid />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
frontend/src/components/landing/Footer.tsx
Normal file
55
frontend/src/components/landing/Footer.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Footer = () => {
|
||||||
|
return (
|
||||||
|
<footer className="glass py-8 px-4" role="contentinfo">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
{/* System Status */}
|
||||||
|
<div className="flex items-center mb-4 md:mb-0">
|
||||||
|
<div className="flex items-center mr-2">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mr-2 pulse"
|
||||||
|
style={{ backgroundColor: 'var(--mass-effect-cyan)' }}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className="text-sm font-mono uppercase tracking-wider"
|
||||||
|
style={{ color: 'var(--mass-effect-cyan)' }}
|
||||||
|
>
|
||||||
|
SYSTEM STATUS: ONLINE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<div className="flex space-x-6 mb-4 md:mb-0">
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
Support
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
API
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
Privacy
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-gray-400 hover:text-cyan-400 transition-colors">
|
||||||
|
Terms
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copyright */}
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
© {new Date().getFullYear()} QUASAR. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
189
frontend/src/components/landing/GameGrid.tsx
Normal file
189
frontend/src/components/landing/GameGrid.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
interface Game {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
coverImage: string;
|
||||||
|
rating: number;
|
||||||
|
genre: string;
|
||||||
|
year: number;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GameGrid = () => {
|
||||||
|
const [hoveredGame, setHoveredGame] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const games: Game[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'Nebula Warriors',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1511512578047-dfb367046420?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=687&q=80',
|
||||||
|
rating: 92,
|
||||||
|
genre: 'Action',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'Multi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Cyber Revolution',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1550745165-9bc0b252726a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 88,
|
||||||
|
genre: 'RPG',
|
||||||
|
year: 2022,
|
||||||
|
platform: 'PC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'Quantum Escape',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1538481199705-c710c4e965fc?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 85,
|
||||||
|
genre: 'Puzzle',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'Console',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Galactic Frontline',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1550745165-9bc0b252726a?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 90,
|
||||||
|
genre: 'Strategy',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'Multi',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Digital Horizon',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1518709268805-4e9042af2176?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1170&q=80',
|
||||||
|
rating: 87,
|
||||||
|
genre: 'Racing',
|
||||||
|
year: 2022,
|
||||||
|
platform: 'Console',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'Shadow Protocol',
|
||||||
|
coverImage:
|
||||||
|
'https://images.unsplash.com/photo-1511512578047-dfb367046420?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=687&q=80',
|
||||||
|
rating: 91,
|
||||||
|
genre: 'Stealth',
|
||||||
|
year: 2023,
|
||||||
|
platform: 'PC',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-4" id="games" aria-labelledby="games-title">
|
||||||
|
<div className="container mx-auto">
|
||||||
|
<h2
|
||||||
|
id="games-title"
|
||||||
|
className="text-3xl md:text-4xl font-bold text-center mb-12 uppercase tracking-wider"
|
||||||
|
style={{
|
||||||
|
color: 'var(--mass-effect-gold)',
|
||||||
|
textShadow: '0 0 10px var(--mass-effect-gold-glow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Game Library
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{games.map((game) => (
|
||||||
|
<Card
|
||||||
|
key={game.id}
|
||||||
|
className="relative overflow-hidden border-0 glass hover-glow cursor-pointer transition-all duration-300"
|
||||||
|
onMouseEnter={() => setHoveredGame(game.id)}
|
||||||
|
onMouseLeave={() => setHoveredGame(null)}
|
||||||
|
>
|
||||||
|
<div className="relative h-64">
|
||||||
|
<Image
|
||||||
|
src={game.coverImage}
|
||||||
|
alt={`Portada del juego ${game.title}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Overlay with game info on hover */}
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-black bg-opacity-80 flex flex-col justify-end p-4 transition-opacity duration-300 ${
|
||||||
|
hoveredGame === game.id ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="text-xl font-bold mb-2"
|
||||||
|
style={{ color: 'var(--mass-effect-cyan)' }}
|
||||||
|
>
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">RATING:</span>
|
||||||
|
<span className="ml-2 font-bold" style={{ color: 'var(--mass-effect-gold)' }}>
|
||||||
|
{game.rating}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">GENRE:</span>
|
||||||
|
<span className="ml-2">{game.genre}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">YEAR:</span>
|
||||||
|
<span className="ml-2">{game.year}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-400">PLATFORM:</span>
|
||||||
|
<span className="ml-2">{game.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holographic border effect */}
|
||||||
|
{hoveredGame === game.id && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 holographic pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-lg font-bold mb-2" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
{game.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: 'var(--mass-effect-gold)' }}
|
||||||
|
></div>
|
||||||
|
<span className="text-sm text-gray-300">{game.genre}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold mr-1"
|
||||||
|
style={{ color: 'var(--mass-effect-gold)' }}
|
||||||
|
>
|
||||||
|
{game.rating}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">/100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GameGrid;
|
||||||
121
frontend/src/components/landing/Hero.tsx
Normal file
121
frontend/src/components/landing/Hero.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
const Hero = () => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="relative min-h-screen flex items-center justify-center overflow-hidden"
|
||||||
|
id="hero"
|
||||||
|
aria-labelledby="hero-title"
|
||||||
|
>
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1446776653964-20c1d3a81b06?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1740&q=80"
|
||||||
|
alt="Fondo espacial con estrellas para el juego destacado"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black opacity-60"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Holographic Border Effect */}
|
||||||
|
<div className="absolute inset-0 z-10 holographic pointer-events-none"></div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-20 text-center px-4 max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2
|
||||||
|
id="hero-title"
|
||||||
|
className="text-3xl md:text-4xl font-bold uppercase tracking-wider mb-4"
|
||||||
|
style={{
|
||||||
|
color: 'var(--mass-effect-gold)',
|
||||||
|
textShadow: '0 0 10px var(--mass-effect-gold-glow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Featured Mission
|
||||||
|
</h2>
|
||||||
|
<h1
|
||||||
|
className="text-5xl md:text-7xl font-bold uppercase tracking-wider mb-6"
|
||||||
|
style={{
|
||||||
|
color: 'var(--mass-effect-cyan)',
|
||||||
|
textShadow: '0 0 15px var(--mass-effect-cyan-glow)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stellar Odyssey
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-lg md:text-xl text-white max-w-2xl mx-auto mb-8"
|
||||||
|
style={{ textShadow: '0 0 5px rgba(0, 0, 0, 0.8)' }}
|
||||||
|
>
|
||||||
|
Embark on an epic journey through uncharted galaxies. Command your starship, explore
|
||||||
|
alien worlds, and uncover the mysteries of the universe in this groundbreaking space
|
||||||
|
exploration adventure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<Button
|
||||||
|
className="btn-mission text-lg px-8 py-3"
|
||||||
|
onClick={() => console.log('Mission Start clicked')}
|
||||||
|
>
|
||||||
|
MISSION START
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black text-lg px-8 py-3"
|
||||||
|
onClick={() => console.log('Learn More clicked')}
|
||||||
|
>
|
||||||
|
LEARN MORE
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Game Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mt-12 max-w-md mx-auto">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
94%
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">RATING</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
50+
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">HOURS</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold" style={{ color: 'var(--mass-effect-cyan)' }}>
|
||||||
|
4K
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-300">GRAPHICS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll Indicator */}
|
||||||
|
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-20">
|
||||||
|
<div className="animate-bounce">
|
||||||
|
<svg
|
||||||
|
width="30"
|
||||||
|
height="30"
|
||||||
|
viewBox="0 0 30 30"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15 20L8 13L9.4 11.6L15 17.2L20.6 11.6L22 13L15 20Z"
|
||||||
|
fill="var(--mass-effect-cyan)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
160
frontend/src/components/landing/Navbar.tsx
Normal file
160
frontend/src/components/landing/Navbar.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
const Navbar = () => {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const toggleMenu = () => {
|
||||||
|
setIsMenuOpen(!isMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 glass">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold text-glow-cyan"
|
||||||
|
style={{ color: 'var(--mass-effect-cyan)' }}
|
||||||
|
>
|
||||||
|
QUASAR
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Bar - Desktop */}
|
||||||
|
<div className="hidden md:flex flex-1 max-w-md mx-8">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="SEARCH GAMES..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="search-glow bg-transparent border border-gray-600 text-white placeholder-gray-400"
|
||||||
|
style={{ borderColor: 'var(--mass-effect-cyan)' }}
|
||||||
|
aria-label="Campo de búsqueda de juegos"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links - Desktop */}
|
||||||
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors"
|
||||||
|
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
|
||||||
|
>
|
||||||
|
GAMES
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors"
|
||||||
|
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
|
||||||
|
>
|
||||||
|
LIBRARY
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors"
|
||||||
|
style={{ textShadow: '0 0 5px var(--mass-effect-cyan-glow)' }}
|
||||||
|
>
|
||||||
|
STATS
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black"
|
||||||
|
>
|
||||||
|
LOGIN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
className="text-white"
|
||||||
|
aria-label={isMenuOpen ? 'Cerrar menú' : 'Abrir menú'}
|
||||||
|
aria-expanded={isMenuOpen}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<line x1="4" y1="6" x2="20" y2="6" />
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12" />
|
||||||
|
<line x1="4" y1="18" x2="20" y2="18" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div className="md:hidden mt-4 glass rounded-lg p-4">
|
||||||
|
{/* Search Bar - Mobile */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="SEARCH GAMES..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="search-glow bg-transparent border border-gray-600 text-white placeholder-gray-400 w-full"
|
||||||
|
style={{ borderColor: 'var(--mass-effect-cyan)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links - Mobile */}
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors py-2"
|
||||||
|
tabIndex={isMenuOpen ? 0 : -1}
|
||||||
|
>
|
||||||
|
GAMES
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors py-2"
|
||||||
|
tabIndex={isMenuOpen ? 0 : -1}
|
||||||
|
>
|
||||||
|
LIBRARY
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-white hover:text-glow-cyan transition-colors py-2"
|
||||||
|
tabIndex={isMenuOpen ? 0 : -1}
|
||||||
|
>
|
||||||
|
STATS
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-cyan-500 text-cyan-500 hover:bg-cyan-500 hover:text-black w-full"
|
||||||
|
>
|
||||||
|
LOGIN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navbar;
|
||||||
64
frontend/src/components/ui/button.tsx
Normal file
64
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
frontend/src/components/ui/card.tsx
Normal file
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
12
package.json
12
package.json
@@ -10,9 +10,13 @@
|
|||||||
"description": "Quasar es una aplicación web para al gestión de una biblioteca personal de videjuegos. Permite a los usuarios catalogar, organizar y buscar sus juegos de manera eficiente. Se pueden agregar videjuegos físicos, digitales y roms de emuladores.",
|
"description": "Quasar es una aplicación web para al gestión de una biblioteca personal de videjuegos. Permite a los usuarios catalogar, organizar y buscar sus juegos de manera eficiente. Se pueden agregar videjuegos físicos, digitales y roms de emuladores.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:install": "playwright install --with-deps",
|
"test:install": "playwright install --with-deps",
|
||||||
"test:ci": "playwright test --reporter=github",
|
"test:ci": "vitest run --reporter=github",
|
||||||
|
"test:playwright": "playwright test",
|
||||||
|
"test:e2e": "playwright test tests/e2e",
|
||||||
|
"test:e2e:debug": "playwright test tests/e2e --debug",
|
||||||
"lint": "eslint . --ext .js,.ts",
|
"lint": "eslint . --ext .js,.ts",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"start": "node src/index.js"
|
"start": "node src/index.js"
|
||||||
@@ -31,7 +35,9 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-prettier": "^5.5.5",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^0.34.1",
|
||||||
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8"
|
"packageManager": "yarn@4.12.0+sha512.f45ab632439a67f8bc759bf32ead036a1f413287b9042726b7cc4818b7b49e14e9423ba49b18f9e06ea4941c1ad062385b1d8760a8d5091a1a31e5f6219afca8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
## Phase 1 Complete: Análisis comparativo de proyectos y servicios
|
|
||||||
|
|
||||||
TL;DR: Se crearon y completaron cuatro documentos de análisis en `docs/` que resumen proyectos relevantes, APIs públicas y consideraciones legales para el MVP. Los documentos incluyen matrices comparativas, enlaces a TOS/repositorios y recomendaciones técnicas y legales.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/competitive-analysis.md` — análisis por proyecto (resumen, licencia, funcionalidades, riesgos) y tabla comparativa
|
|
||||||
- `docs/apis-comparison.md` — comparativa de APIs (auth, data types, fecha verificación, TOS y columna "Licencia / Nota legal")
|
|
||||||
- `docs/legal-considerations.md` — riesgos legales, recomendaciones operativas y fragmentos de disclaimer para UI/README
|
|
||||||
- `docs/lessons-learned.md` — lista priorizada de funcionalidades, PoC propuesta y recomendaciones técnicas
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (el usuario solicitó no crear tests para esta fase)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
chore: add comparative analysis docs
|
|
||||||
|
|
||||||
- Add `docs/competitive-analysis.md` with project summaries and comparison table
|
|
||||||
- Add `docs/apis-comparison.md` with API TOS links and license notes
|
|
||||||
- Add `docs/legal-considerations.md` and `docs/lessons-learned.md` with recommendations and PoC
|
|
||||||
- Add `Metadatos` block (Autor / Fecha verificación: 2026-02-07 / Última actualización)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 2 Complete: Requisitos y diseño técnico
|
|
||||||
|
|
||||||
TL;DR: Se documentaron y finalizaron los requisitos funcionales y no funcionales del MVP, el diseño de arquitectura (monorepo, stack propuesto) y el modelo de datos inicial para `Game`, `RomFile`, `Platform`, `Artwork`, `Purchase` y `PriceHistory`.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- `docs/requirements.md`
|
|
||||||
- `docs/architecture.md`
|
|
||||||
- `docs/api-integration.md`
|
|
||||||
- `docs/data-model.md`
|
|
||||||
- `plans/gestor-coleccion-plan.md` (plan maestro actualizado)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- Ninguna (documentación)
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- Ninguno (recomendación: añadir tests que verifiquen la presencia y metadatos de los documentos claves si se automatiza la validación de docs en CI)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED ✅ (con recomendación menor: añadir `docs/legal-considerations.md` si falta para cubrir riesgos legales antes de integrar scraping o descargas masivas)
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
|
|
||||||
```
|
|
||||||
chore(docs): completar Fase 2 — requisitos y arquitectura
|
|
||||||
|
|
||||||
- Añade/actualiza `docs/requirements.md`, `docs/architecture.md`, `docs/api-integration.md`, `docs/data-model.md`
|
|
||||||
- Documenta criterios de aceptación y decisiones técnico-arquitectónicas
|
|
||||||
- Recomendación: añadir `docs/legal-considerations.md` (pendiente)
|
|
||||||
```
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
## Phase 3 Complete: ArchiveReader
|
|
||||||
|
|
||||||
TL;DR: Implementado `archiveReader` para listar entradas dentro de contenedores ZIP y 7z usando utilidades del sistema (`7z` y `unzip` como fallback). Añadidos tests unitarios que mockean las llamadas a `child_process.exec` para validar parsing y comportamiento de fallback.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/archiveReader.ts
|
|
||||||
- backend/tests/services/archiveReader.spec.ts
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `listArchiveEntries(filePath, logger)` — lista entradas de ZIP/7z usando `7z -slt` y `unzip -l` como fallback.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/archiveReader.spec.ts` — cubre:
|
|
||||||
- listado con salida simulada de `7z -slt`
|
|
||||||
- fallback a `unzip -l` si `7z` falla
|
|
||||||
- comportamiento para formatos no soportados
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add archive reader and tests
|
|
||||||
|
|
||||||
- Añade `archiveReader` que lista entradas en ZIP/7z con fallback a `unzip`
|
|
||||||
- Añade tests unitarios que mockean `child_process.exec` para validar parsing
|
|
||||||
- Documenta dependencia de binarios en README y CI (pasos previos)
|
|
||||||
|
|
||||||
## Phase 3 Complete: Backend base y modelo de datos
|
|
||||||
|
|
||||||
Fase completada: configuré el backend mínimo (dependencias, Prisma schema), generé el cliente Prisma y aseguré que los tests TDD de backend pasan.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/package.json
|
|
||||||
- backend/prisma/schema.prisma
|
|
||||||
- backend/tests/models/game.spec.ts
|
|
||||||
- package.json
|
|
||||||
- .yarnrc.yml
|
|
||||||
- prisma-client/package.json
|
|
||||||
|
|
||||||
**Files generados por herramientas (no necesariamente versionadas):**
|
|
||||||
|
|
||||||
- prisma-client/client/\* (Prisma Client generado)
|
|
||||||
- node_modules/.prisma/client/\* (artefacto runtime generado)
|
|
||||||
|
|
||||||
**Functions / cambios clave:**
|
|
||||||
|
|
||||||
- Ajustes en `backend/tests/models/game.spec.ts` para fallback de carga del cliente Prisma generado.
|
|
||||||
- `backend/prisma/schema.prisma`: definición de modelos (Game, RomFile, Platform, Purchase, Artwork, Tag, PriceHistory) ya presente; ajustado el `generator client` para flujo de generación local.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- backend/tests/models/game.spec.ts (modificado: mejor manejo de require/generación del cliente)
|
|
||||||
- backend/tests/server.spec.ts (existente — pase verificable)
|
|
||||||
|
|
||||||
**Migraciones aplicadas durante pruebas:**
|
|
||||||
|
|
||||||
- `backend/prisma/migrations/20260208102247_init/migration.sql` (aplicada en DB temporal de test)
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: backend base, Prisma schema, client gen and tests
|
|
||||||
|
|
||||||
- Añade/ajusta `backend` para usar Prisma y Vitest
|
|
||||||
- Genera cliente Prisma y corrige resoluciones PnP/node-modules
|
|
||||||
- Actualiza tests para cargar cliente generado y pasar TDD
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## Phase 4 Complete: DAT verifier
|
|
||||||
|
|
||||||
TL;DR: Implementado `datVerifier` para parsear archivos DAT (XML) y verificar hashes de ROMs (CRC/MD5/SHA1/size). Se añadieron tests TDD y una fixture XML; los tests específicos pasan y se aplicó un parche menor de calidad.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/services/datVerifier.ts
|
|
||||||
- backend/tests/services/datVerifier.spec.ts
|
|
||||||
- backend/tests/fixtures/sample.dat.xml
|
|
||||||
- backend/package.json (se añadió `fast-xml-parser` en devDependencies)
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `parseDat(xml: string): DatDatabase` — parsea y normaliza la estructura DAT a un modelo en memoria.
|
|
||||||
- `verifyHashesAgainstDat(datDb: DatDatabase, hashes): {gameName, romName, matchedOn} | null` — verifica hashes contra el DAT y devuelve la coincidencia.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` — cubre parsing, match por CRC/MD5/SHA1/size y ausencia de match.
|
|
||||||
- `backend/tests/fixtures/sample.dat.xml` — fixture usada por las pruebas.
|
|
||||||
|
|
||||||
**Review Status:** APPROVED with minor recommendations
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: add datVerifier and tests
|
|
||||||
|
|
||||||
- Añade `datVerifier` con `parseDat` y `verifyHashesAgainstDat`
|
|
||||||
- Añade tests y fixture XML para validar matching por CRC/MD5/SHA1/size
|
|
||||||
- Añade `fast-xml-parser` en `backend/package.json` (devDependency)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
## Phase 5 Complete: Job runner en memoria
|
|
||||||
|
|
||||||
TL;DR: Se implementó un runner en memoria (`ImportRunner`) con control de concurrencia configurable, API de encolado (`enqueue`), estado (`getStatus`) y utilidades de parada (`stop`, `stopAndWait`). Se añadieron tests TDD que cubren concurrencia, rechazo tras `stop` y contabilización de tareas completadas. La ruta de importación ahora encola jobs en background y registra errores.
|
|
||||||
|
|
||||||
**Files created/changed:**
|
|
||||||
|
|
||||||
- backend/src/config.ts
|
|
||||||
- backend/src/jobs/importRunner.ts
|
|
||||||
- backend/src/routes/import.ts
|
|
||||||
- backend/tests/jobs/importRunner.spec.ts
|
|
||||||
- backend/tsconfig.json
|
|
||||||
|
|
||||||
**Functions created/changed:**
|
|
||||||
|
|
||||||
- `ImportRunner` (class) — `enqueue`, `getStatus`, `start`, `stop`, `stopAndWait`.
|
|
||||||
- `runner` (singleton) — instanciado y arrancado por defecto.
|
|
||||||
- `IMPORT_CONCURRENCY` (export) in `config.ts`.
|
|
||||||
|
|
||||||
**Tests created/changed:**
|
|
||||||
|
|
||||||
- `backend/tests/jobs/importRunner.spec.ts` — 5–6 tests (enqueue result, concurrencia, getStatus, rechazo tras stop, completed incrementa en rechazo).
|
|
||||||
|
|
||||||
**Review Status:** APPROVED
|
|
||||||
|
|
||||||
**Git Commit Message:**
|
|
||||||
feat: import job runner in-memory
|
|
||||||
|
|
||||||
- Añade `ImportRunner` en memoria con concurrencia configurable
|
|
||||||
- Tests TDD para enqueue, concurrencia y comportamiento tras `stop`
|
|
||||||
- Actualiza `/api/import/scan` para encolar jobs y registrar errores
|
|
||||||
- Ajusta `tsconfig.json` para incluir `tests` en comprobaciones de tipo
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
## Plan: Gestor de biblioteca de videojuegos y ROMs (Quasar)
|
|
||||||
|
|
||||||
Aplicación web self-hosted para gestionar una biblioteca de ROMs y videojuegos físicos/digitales. Permite escanear directorios de ROMs, enriquecer metadatos vía APIs públicas (IGDB, RAWG, TheGamesDB), y registrar manualmente juegos físicos/digitales con precio, condición y notas. Stack: TypeScript + React + Vite + shadcn/ui (frontend), Node.js + Fastify + TypeScript + Prisma + SQLite (backend).
|
|
||||||
|
|
||||||
**Fases: 9**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 1: Análisis comparativo de proyectos y servicios**
|
|
||||||
|
|
||||||
- **Objetivo:** Documentar todos los proyectos, herramientas y APIs analizados durante la investigación inicial, describiendo qué hace cada uno, sus características principales, licencias, y lecciones aprendidas para aplicar a Quasar.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `docs/competitive-analysis.md` — análisis detallado de proyectos (Playnite, LaunchBox, OpenEmu, EmulationStation, RetroArch, ROMVault, etc.)
|
|
||||||
- `docs/apis-comparison.md` — comparativa de APIs (IGDB, RAWG, TheGamesDB, Screenscraper, MobyGames, PriceCharting, ITAD, eBay)
|
|
||||||
- `docs/lessons-learned.md` — patrones y mejores prácticas extraídas del análisis
|
|
||||||
- **Pasos:**
|
|
||||||
1. Crear documentos con información estructurada de la investigación inicial
|
|
||||||
2. Incluir tablas comparativas, enlaces, y conclusiones
|
|
||||||
3. Documentar patrones útiles y mejores prácticas aplicables a Quasar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 2: Requisitos y diseño técnico**
|
|
||||||
|
|
||||||
- **Objetivo:** Definir arquitectura (monorepo o separado), estructura de carpetas, stack definitivo (Fastify + Prisma, SQLite), APIs a integrar (IGDB, RAWG, TheGamesDB), y documento de modelo de datos inicial.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `docs/requirements.md` — requisitos funcionales y no funcionales
|
|
||||||
- `docs/architecture.md` — decisiones arquitectónicas (monorepo vs multi-repo, API REST structure)
|
|
||||||
- `docs/api-integration.md` — descripción de APIs públicas a usar, endpoints, rate limits, autenticación
|
|
||||||
- `docs/data-model.md` — entidades (Game, RomFile, Platform, Purchase, Artwork)
|
|
||||||
- **Pasos:**
|
|
||||||
1. Crear documentos `docs/requirements.md`, `docs/architecture.md`, `docs/api-integration.md`, `docs/data-model.md` con contenido inicial
|
|
||||||
2. Definir estructura de carpetas y convenciones de código
|
|
||||||
3. Documentar decisiones técnicas y justificaciones
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 3: Backend base y modelo de datos**
|
|
||||||
|
|
||||||
- **Objetivo:** Configurar backend (Fastify + TypeScript + Prisma + SQLite), definir schema de BD (Game, RomFile, Platform, Purchase, Artwork), migraciones y seeders básicos.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/package.json` — dependencias (fastify, prisma, @fastify/cors, dotenv, etc.)
|
|
||||||
- `backend/tsconfig.json` — configuración TypeScript backend
|
|
||||||
- `backend/src/index.ts` — servidor Fastify inicial
|
|
||||||
- `backend/prisma/schema.prisma` — modelos (Game, RomFile, Platform, Purchase, Artwork)
|
|
||||||
- `backend/prisma/migrations/` — migraciones Prisma
|
|
||||||
- `backend/src/routes/healthcheck.ts` — endpoint `/api/health`
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/server.spec.ts` — test del servidor (inicia y responde en `/api/health`)
|
|
||||||
- `backend/tests/models/game.spec.ts` — validaciones del modelo Game (TDD)
|
|
||||||
- `backend/tests/models/romFile.spec.ts` — validaciones del modelo RomFile
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (healthcheck endpoint, crear modelo Game y validar)
|
|
||||||
2. Configurar Fastify + Prisma, definir schema, ejecutar migración
|
|
||||||
3. Implementar endpoint `/api/health`
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 4: Importadores y gestión de ROMs**
|
|
||||||
|
|
||||||
- **Objetivo:** Implementar servicio para escanear directorios locales, calcular checksums (CRC32/MD5/SHA1), detectar formatos (ZIP/7z/CHD), y almacenar en BD. Incluir soporte básico para DAT verification (No-Intro/Redump).
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/services/fsScanner.ts` — función `scanDirectory(path: string)`
|
|
||||||
- `backend/src/services/checksumService.ts` — funciones `calculateCRC32()`, `calculateMD5()`, `calculateSHA1()`
|
|
||||||
- `backend/src/services/datVerifier.ts` — función `verifyAgainstDAT(romFiles, datPath)`
|
|
||||||
- `backend/src/routes/import.ts` — endpoint `POST /api/import/scan` (body: {path})
|
|
||||||
- `backend/src/utils/archiveReader.ts` — leer contenido de ZIP/7z/CHD
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/services/fsScanner.spec.ts` — casos: carpeta vacía, carpeta con ROMs, carpeta con subdirectorios
|
|
||||||
- `backend/tests/services/checksumService.spec.ts` — calcular checksum de archivo fixture
|
|
||||||
- `backend/tests/services/datVerifier.spec.ts` — verificar ROM válido/inválido contra DAT fixture
|
|
||||||
- `backend/tests/routes/import.spec.ts` — test E2E de endpoint `/api/import/scan`
|
|
||||||
- **Pasos:**
|
|
||||||
1. Crear fixtures (ROMs de prueba, DAT de prueba)
|
|
||||||
2. Escribir tests que fallen (escaneo de carpeta, checksum, DAT verification)
|
|
||||||
3. Implementar `fsScanner`, `checksumService`, `datVerifier` mínimos
|
|
||||||
4. Implementar endpoint `/api/import/scan`
|
|
||||||
5. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 5: Integración con APIs de metadata**
|
|
||||||
|
|
||||||
- **Objetivo:** Clientes para IGDB (OAuth Twitch), RAWG (API key), TheGamesDB (API key); lógica de matching heurística (nombre + plataforma), caché local de respuestas para evitar rate limits.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/services/igdbClient.ts` — `searchGames(query, platform?)`, `getGameById(id)`
|
|
||||||
- `backend/src/services/rawgClient.ts` — `searchGames(query)`, `getGameById(id)`
|
|
||||||
- `backend/src/services/thegamesdbClient.ts` — `searchGames(query)`, `getGameById(id)`
|
|
||||||
- `backend/src/services/metadataService.ts` — `enrichGame(romFile)` (orquesta clientes, fallbacks, matching heurística)
|
|
||||||
- `backend/src/utils/cache.ts` — caché en memoria o Redis (simple LRU)
|
|
||||||
- `backend/src/routes/metadata.ts` — endpoints `GET /api/metadata/search?q=...&platform=...`, `POST /api/metadata/enrich/:romFileId`
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/services/igdbClient.spec.ts` — mock de respuestas IGDB, test de OAuth flow, test de búsqueda
|
|
||||||
- `backend/tests/services/rawgClient.spec.ts` — mock de respuestas RAWG
|
|
||||||
- `backend/tests/services/metadataService.spec.ts` — casos: match exacto, match parcial, sin match (fallback)
|
|
||||||
- `backend/tests/routes/metadata.spec.ts` — test E2E de endpoints metadata
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests con mocks (respuestas API simuladas)
|
|
||||||
2. Implementar clientes (IGDB OAuth, RAWG key, TheGamesDB key) con retry y timeout
|
|
||||||
3. Implementar `metadataService` con lógica de matching y fallbacks
|
|
||||||
4. Implementar endpoints REST
|
|
||||||
5. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 6: Frontend base (React + Vite + shadcn/ui)**
|
|
||||||
|
|
||||||
- **Objetivo:** Configurar proyecto frontend con Vite, React, TypeScript, Tailwind CSS, shadcn/ui, TanStack Query, TanStack Router. Implementar layout base (navbar, sidebar), rutas (Home, ROMs, Games, Settings) y componentes UI básicos (Button, Card, Table, Dialog de shadcn/ui).
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/package.json` — dependencias (react, vite, @shadcn/ui, tailwindcss, @tanstack/react-router, @tanstack/react-query)
|
|
||||||
- `frontend/tsconfig.json` — configuración TypeScript frontend
|
|
||||||
- `frontend/vite.config.ts` — configuración Vite (proxy a backend, aliases, TanStack Router plugin)
|
|
||||||
- `frontend/tailwind.config.js` — configuración Tailwind para shadcn/ui
|
|
||||||
- `frontend/src/main.tsx` — entry point con QueryClientProvider y RouterProvider
|
|
||||||
- `frontend/src/routes/__root.tsx` — layout raíz con navbar y sidebar
|
|
||||||
- `frontend/src/routes/index.tsx` — ruta Home
|
|
||||||
- `frontend/src/routes/roms.tsx` — ruta ROMs
|
|
||||||
- `frontend/src/routes/games.tsx` — ruta Games
|
|
||||||
- `frontend/src/components/layout/Navbar.tsx`
|
|
||||||
- `frontend/src/components/layout/Sidebar.tsx`
|
|
||||||
- `frontend/src/lib/api.ts` — cliente HTTP base (fetch/axios wrapper)
|
|
||||||
- `frontend/src/lib/queryClient.ts` — configuración de TanStack Query
|
|
||||||
- `frontend/src/hooks/useGames.ts` — custom hook con TanStack Query para juegos
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `frontend/tests/App.spec.tsx` — renderizado de rutas (usando Vitest + React Testing Library)
|
|
||||||
- `frontend/tests/components/Navbar.spec.tsx` — navegación básica
|
|
||||||
- `tests/e2e/navigation.spec.ts` — E2E con Playwright (navegar entre páginas)
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (renderizado de App, navegación E2E)
|
|
||||||
2. Configurar Vite + React + TypeScript + Tailwind + shadcn/ui + TanStack Query
|
|
||||||
3. Implementar layout, rutas, páginas vacías, configuración de QueryClient
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 7: Gestión manual de juegos (frontend + backend)**
|
|
||||||
|
|
||||||
- **Objetivo:** CRUD completo para juegos: crear/editar/eliminar juegos manualmente (frontend form con shadcn/ui + TanStack Query), registrar juegos físicos/digitales con campos: nombre, plataforma, precio, condición (Loose/CIB/New), fecha de compra, vendedor, notas.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `backend/src/routes/games.ts` — `GET /api/games`, `POST /api/games`, `PUT /api/games/:id`, `DELETE /api/games/:id`
|
|
||||||
- `backend/src/controllers/gamesController.ts` — lógica de CRUD
|
|
||||||
- `backend/src/validators/gameValidator.ts` — validación de input (Zod/Joi)
|
|
||||||
- `frontend/src/routes/games.tsx` — tabla de juegos con acciones (editar, eliminar)
|
|
||||||
- `frontend/src/components/games/GameForm.tsx` — formulario para crear/editar juego
|
|
||||||
- `frontend/src/components/games/GameCard.tsx` — card de vista de juego
|
|
||||||
- `frontend/src/hooks/useGames.ts` — custom hooks con TanStack Query (useGames, useCreateGame, useUpdateGame, useDeleteGame)
|
|
||||||
- `frontend/src/components/ui/*` — shadcn/ui components (Form, Input, Select, Textarea, DatePicker)
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `backend/tests/routes/games.spec.ts` — CRUD endpoints (casos: crear juego válido, crear juego con datos faltantes, actualizar, eliminar)
|
|
||||||
- `frontend/tests/routes/games.spec.tsx` — renderizado de lista, acciones
|
|
||||||
- `frontend/tests/components/GameForm.spec.tsx` — validación de formulario
|
|
||||||
- `tests/e2e/games-crud.spec.ts` — E2E completo (crear/editar/eliminar juego)
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (endpoints CRUD, renderizado de form, E2E CRUD)
|
|
||||||
2. Implementar backend endpoints y validators
|
|
||||||
3. Implementar frontend page, form, custom hooks con TanStack Query
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 8: Integración ROMs + Metadata (UI completa)**
|
|
||||||
|
|
||||||
- **Objetivo:** Vista de ROMs escaneados en frontend, botón para scan de directorio, búsqueda/asociación de metadata (UI para seleccionar resultado de IGDB/RAWG con TanStack Query), y vincular ROM con juego. Incluir vista de artwork (covers, screenshots).
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `frontend/src/routes/roms.tsx` — tabla de ROMs escaneados, botón "Scan Directory", acciones (asociar metadata, ver detalles)
|
|
||||||
- `frontend/src/components/roms/ScanDialog.tsx` — dialog para input de path y scan
|
|
||||||
- `frontend/src/components/roms/MetadataSearchDialog.tsx` — búsqueda y selección de metadata
|
|
||||||
- `frontend/src/components/roms/RomCard.tsx` — card con info de ROM + artwork
|
|
||||||
- `frontend/src/hooks/useRoms.ts` — custom hooks con TanStack Query (useRoms, useScanDirectory, useEnrichMetadata)
|
|
||||||
- `backend/src/routes/artwork.ts` — `GET /api/artwork/:gameId` (proxy/cache de imágenes)
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `frontend/tests/routes/roms.spec.tsx` — renderizado de tabla, acciones
|
|
||||||
- `frontend/tests/components/ScanDialog.spec.tsx` — submit de path, loading state
|
|
||||||
- `tests/e2e/roms-import.spec.ts` — E2E: scan → ver ROMs → asociar metadata → ver juego enriquecido
|
|
||||||
- **Pasos:**
|
|
||||||
1. Escribir tests que fallen (UI de ROMs, scan dialog, E2E import completo)
|
|
||||||
2. Implementar componentes frontend y custom hooks con TanStack Query
|
|
||||||
3. Implementar endpoint de artwork (cache/proxy)
|
|
||||||
4. Ejecutar tests y verificar que pasan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### **Fase 9: CI, tests E2E, docs y seguridad**
|
|
||||||
|
|
||||||
- **Objetivo:** GitHub Actions para CI (lint, tests unitarios, tests E2E con Playwright), configuración de ESLint/Prettier, docs de uso de APIs (cómo obtener keys), seguridad (env vars, .gitignore actualizado, SECURITY.md), y README completo.
|
|
||||||
- **Archivos/Funciones a crear/modificar:**
|
|
||||||
- `.github/workflows/ci.yml` — pipeline (install, lint, test, e2e)
|
|
||||||
- `.eslintrc.cjs` — ajustar para backend + frontend
|
|
||||||
- `.prettierrc` — ajustar formatos
|
|
||||||
- `README.md` — actualizar con: setup, features, screenshots, roadmap
|
|
||||||
- `SECURITY.md` — políticas de seguridad, reporte de vulnerabilidades
|
|
||||||
- `docs/API_KEYS.md` — instrucciones para obtener keys de IGDB, RAWG, TheGamesDB
|
|
||||||
- `.env.example` — template de variables de entorno
|
|
||||||
- `backend/.env.example`, `frontend/.env.example` — templates específicos
|
|
||||||
- **Tests a escribir:**
|
|
||||||
- `tests/e2e/full-flow.spec.ts` — E2E completo end-to-end (scan → enrich → crear juego manual → view)
|
|
||||||
- Asegurar que CI ejecuta todos los tests y Playwright genera reporte
|
|
||||||
- **Pasos:**
|
|
||||||
1. Configurar GitHub Actions workflow
|
|
||||||
2. Ejecutar pipeline en CI (puede fallar inicialmente)
|
|
||||||
3. Corregir issues de lint/format/tests
|
|
||||||
4. Actualizar docs (README, SECURITY, API_KEYS)
|
|
||||||
5. Ejecutar CI nuevamente y verificar que todo pasa
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Preguntas abiertas resueltas** ✅
|
|
||||||
|
|
||||||
1. ✅ App web self-hosted (server + frontend separados)
|
|
||||||
2. ✅ Frontend: TypeScript + React + Vite + shadcn/ui + TanStack Query + TanStack Router
|
|
||||||
3. ✅ Solo APIs públicas: IGDB (OAuth Twitch gratuito), RAWG (API key gratuita con atribución), TheGamesDB (API key gratuita)
|
|
||||||
4. ✅ Sin integración con tiendas (Steam/GOG/PSN) en MVP; dejar preparado para futuro (campo `storeId`, `storePlatform` en modelo Game)
|
|
||||||
5. ✅ Prioridad: gestión de ROMs de directorio + creación manual de juegos físicos/digitales
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Decisiones técnicas clave** 🔧
|
|
||||||
|
|
||||||
- **Monorepo:** `/backend` y `/frontend` en el mismo repo, con workspaces de Yarn
|
|
||||||
- **Backend:** Node.js + Fastify + TypeScript + Prisma + SQLite (migration a PostgreSQL posible en futuro)
|
|
||||||
- **Frontend:** React + Vite + TypeScript + Tailwind CSS + shadcn/ui + TanStack Query + TanStack Router
|
|
||||||
- **APIs de metadata:** IGDB (primary), RAWG (fallback), TheGamesDB (artwork/retro)
|
|
||||||
- **Tests:** Backend (Vitest + Supertest), Frontend (Vitest + React Testing Library), E2E (Playwright)
|
|
||||||
- **CI:** GitHub Actions con pipeline: install → lint → test → e2e
|
|
||||||
- **Seguridad:** API keys en `.env`, no commitear secrets
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## **Roadmap futuro (fuera del MVP)** 🚀
|
|
||||||
|
|
||||||
- Integración con tiendas (Steam/GOG/PSN/Xbox) para import automático de compras
|
|
||||||
- Price tracking con PriceCharting/IsThereAnyDeal (requiere suscripción/API paga)
|
|
||||||
- Plugins/extensiones (LaunchBox export, Playnite sync)
|
|
||||||
- Sincronización en nube (opcional, con cifrado E2E)
|
|
||||||
- Soporte para multiple usuarios (autenticación/autorización)
|
|
||||||
- Mobile app (React Native o PWA)
|
|
||||||
@@ -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?
|
|
||||||
@@ -2,21 +2,27 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: 'tests',
|
testDir: 'tests',
|
||||||
|
testMatch: '**/*.spec.ts',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
},
|
},
|
||||||
fullyParallel: true,
|
fullyParallel: false, // Set to false for E2E to avoid race conditions
|
||||||
reporter: 'list',
|
reporter: [['list'], ['html']],
|
||||||
use: {
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173', // Frontend URL
|
||||||
headless: true,
|
headless: true,
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
actionTimeout: 0,
|
actionTimeout: 10_000,
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Global timeout
|
||||||
|
globalTimeout: 60 * 60 * 1000, // 1 hour
|
||||||
});
|
});
|
||||||
|
|||||||
70
tests/documentation.spec.ts
Normal file
70
tests/documentation.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
describe('Documentation - Security and API Keys', () => {
|
||||||
|
// SECURITY.md tests
|
||||||
|
it('SECURITY.md exists and contains "Reporting Security Vulnerabilities"', () => {
|
||||||
|
expect(existsSync('./SECURITY.md')).toBe(true);
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Reporting Security Vulnerabilities');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Environment Variables & Secrets" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Environment Variables & Secrets');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Input Validation & Sanitization" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Input Validation & Sanitization');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Rate Limiting" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Rate Limiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SECURITY.md contains "Database Security" section', () => {
|
||||||
|
const content = readFileSync('./SECURITY.md', 'utf-8');
|
||||||
|
expect(content).toContain('Database Security');
|
||||||
|
});
|
||||||
|
|
||||||
|
// docs/API_KEYS.md tests
|
||||||
|
it('docs/API_KEYS.md exists and contains "IGDB" section', () => {
|
||||||
|
expect(existsSync('./docs/API_KEYS.md')).toBe(true);
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toContain('IGDB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs/API_KEYS.md contains "RAWG" section', () => {
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toContain('RAWG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs/API_KEYS.md contains "TheGamesDB" section', () => {
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toContain('TheGamesDB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs/API_KEYS.md contains step-by-step instructions', () => {
|
||||||
|
const content = readFileSync('./docs/API_KEYS.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/steps?|step-by-step|guide/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// README.md tests
|
||||||
|
it('README.md contains link to SECURITY.md', () => {
|
||||||
|
const content = readFileSync('./README.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/SECURITY\.md|security/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('README.md contains link to docs/API_KEYS.md', () => {
|
||||||
|
const content = readFileSync('./README.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/API_KEYS\.md|api.*key|obtaining.*key/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('README.md mentions .env.example template', () => {
|
||||||
|
const content = readFileSync('./README.md', 'utf-8');
|
||||||
|
expect(content).toMatch(/\.env|environment.*variable/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
194
tests/e2e/full-flow.spec.ts
Normal file
194
tests/e2e/full-flow.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Quasar E2E Tests - Full User Journey', () => {
|
||||||
|
// All tests assume backend runs on http://localhost:3000
|
||||||
|
// and frontend runs on http://localhost:5173 (with proxy to /api)
|
||||||
|
|
||||||
|
test('E2E: Navigate to home page and verify layout', async ({ page }) => {
|
||||||
|
// Navigate to home page
|
||||||
|
await page.goto('http://localhost:5173/');
|
||||||
|
|
||||||
|
// Verify page loads without errors
|
||||||
|
await expect(page).toHaveTitle(/Quasar|Games/i);
|
||||||
|
|
||||||
|
// Verify navigation links exist
|
||||||
|
const gamesLink = page.locator('a:has-text("Games")').first();
|
||||||
|
const romsLink = page.locator('a:has-text("ROMs")').first();
|
||||||
|
|
||||||
|
await expect(gamesLink).toBeVisible();
|
||||||
|
await expect(romsLink).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Create a game manually via form', async ({ page }) => {
|
||||||
|
// Navigate to games page
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click "Add Game" button
|
||||||
|
const addGameBtn = page.locator('button:has-text("Add Game")');
|
||||||
|
await expect(addGameBtn).toBeVisible();
|
||||||
|
await addGameBtn.click();
|
||||||
|
|
||||||
|
// Wait for form to appear
|
||||||
|
const titleInput = page.locator('#title');
|
||||||
|
await expect(titleInput).toBeVisible();
|
||||||
|
|
||||||
|
// Fill form: title and platform
|
||||||
|
await titleInput.fill('The Legend of Zelda');
|
||||||
|
|
||||||
|
const platformInput = page.locator('#platformId');
|
||||||
|
await platformInput.fill('Nintendo 64');
|
||||||
|
|
||||||
|
// Submit form - look for button with Submit/Save/Create text
|
||||||
|
const submitBtn = page.locator('button[type="submit"]').first();
|
||||||
|
await submitBtn.click();
|
||||||
|
|
||||||
|
// Wait for API response and refresh
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify game appears in table
|
||||||
|
const gameInTable = page.locator('text=The Legend of Zelda');
|
||||||
|
await expect(gameInTable).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Search metadata for a game', async ({ page }) => {
|
||||||
|
// Navigate to ROMs page (metadata search trigger)
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Click "Scan Directory" or find metadata search button
|
||||||
|
const scanBtn = page.locator('button:has-text("Scan Directory")');
|
||||||
|
if (await scanBtn.isVisible()) {
|
||||||
|
// If there's a scan button, we'd click it, but for this test
|
||||||
|
// we'll focus on metadata search dialog
|
||||||
|
// In real scenario, would fill scan path and trigger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Create a game first, then search metadata for it
|
||||||
|
// For now, we'll just verify the metadata search dialog can be triggered
|
||||||
|
const linkMetadataBtn = page.locator('button:has-text("Link Metadata")').first();
|
||||||
|
|
||||||
|
// Skip if no ROMs yet
|
||||||
|
if (!(await linkMetadataBtn.isVisible())) {
|
||||||
|
// Create a game first so we have something to link
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.locator('button:has-text("Add Game")').click();
|
||||||
|
await page.locator('#title').fill('Super Mario');
|
||||||
|
await page.locator('#platformId').fill('Nintendo');
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
|
||||||
|
// Go back to ROMs
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metadata search dialog can be opened
|
||||||
|
const linkBtns = page.locator('button:has-text("Link Metadata")');
|
||||||
|
if ((await linkBtns.count()) > 0) {
|
||||||
|
await linkBtns.first().click();
|
||||||
|
|
||||||
|
// Wait for metadata dialog to appear
|
||||||
|
const dialog = page.locator('[role="dialog"]').or(page.locator('.modal')).first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Link ROM to game', async ({ page }) => {
|
||||||
|
// This test requires:
|
||||||
|
// 1. At least one game created
|
||||||
|
// 2. At least one ROM in the system
|
||||||
|
|
||||||
|
// First, create a game
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const addGameBtn = page.locator('button:has-text("Add Game")');
|
||||||
|
if (await addGameBtn.isVisible()) {
|
||||||
|
await addGameBtn.click();
|
||||||
|
await page.locator('#title').fill('Zelda');
|
||||||
|
await page.locator('#platformId').fill('Nintendo');
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now go to ROMs and try to link
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
const linkBtns = page.locator('button:has-text("Link Metadata")');
|
||||||
|
const linkCount = await linkBtns.count();
|
||||||
|
|
||||||
|
if (linkCount > 0) {
|
||||||
|
// Click first "Link Metadata" button
|
||||||
|
await linkBtns.first().click();
|
||||||
|
|
||||||
|
// Wait for dialog with game selection
|
||||||
|
const dialog = page.locator('[role="dialog"], .modal').first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Try to select "Zelda" game from results
|
||||||
|
const zelda = page.locator('text=Zelda').nth(1); // nth(1) to skip the header
|
||||||
|
if (await zelda.isVisible()) {
|
||||||
|
await zelda.click();
|
||||||
|
|
||||||
|
// Wait for link to complete
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Verify ROM now shows "Zelda" in Game column
|
||||||
|
const gameCell = page.locator('td:has-text("Zelda")');
|
||||||
|
await expect(gameCell).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('E2E: Full user journey - create, search, link, view', async ({ page }) => {
|
||||||
|
// Step 1: Create game "Hades"
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('button:has-text("Add Game")').click();
|
||||||
|
await page.locator('#title').fill('Hades');
|
||||||
|
await page.locator('#platformId').fill('Nintendo Switch');
|
||||||
|
await page.locator('button[type="submit"]').first().click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Verify game appears in games list
|
||||||
|
let hadesInGames = page.locator('text=Hades').first();
|
||||||
|
await expect(hadesInGames).toBeVisible();
|
||||||
|
|
||||||
|
// Step 2: Navigate to ROMs and verify we can search metadata
|
||||||
|
await page.goto('http://localhost:5173/roms');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// If there are ROMs, try to link one to Hades
|
||||||
|
const linkBtns = page.locator('button:has-text("Link Metadata")');
|
||||||
|
if ((await linkBtns.count()) > 0) {
|
||||||
|
// Open metadata search
|
||||||
|
await linkBtns.first().click();
|
||||||
|
|
||||||
|
// Wait for dialog
|
||||||
|
const dialog = page.locator('[role="dialog"], .modal').first();
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Try to find and select Hades
|
||||||
|
const hadesOption = page.locator('text=Hades');
|
||||||
|
const count = await hadesOption.count();
|
||||||
|
if (count > 1) {
|
||||||
|
// Select second occurrence (avoiding button text, if any)
|
||||||
|
await hadesOption.nth(1).click();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Verify in games view
|
||||||
|
await page.goto('http://localhost:5173/games');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
hadesInGames = page.locator('text=Hades');
|
||||||
|
await expect(hadesInGames).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
tests/env-example.spec.ts
Normal file
89
tests/env-example.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
describe('Environment Example Files', () => {
|
||||||
|
describe('Root .env.example', () => {
|
||||||
|
const rootEnvPath = resolve(__dirname, '..', '.env.example');
|
||||||
|
|
||||||
|
it('should exist and contain DATABASE_URL', () => {
|
||||||
|
expect(existsSync(rootEnvPath)).toBe(true);
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('DATABASE_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain IGDB_CLIENT_ID and IGDB_CLIENT_SECRET', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_ID');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_SECRET');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain RAWG_API_KEY', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('RAWG_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain THEGAMESDB_API_KEY', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('THEGAMESDB_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain NODE_ENV', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('NODE_ENV');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain PORT and LOG_LEVEL', () => {
|
||||||
|
const content = readFileSync(rootEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('PORT');
|
||||||
|
expect(content).toContain('LOG_LEVEL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Backend .env.example', () => {
|
||||||
|
const backendEnvPath = resolve(__dirname, '..', 'backend', '.env.example');
|
||||||
|
|
||||||
|
it('should exist', () => {
|
||||||
|
expect(existsSync(backendEnvPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain DATABASE_URL', () => {
|
||||||
|
const content = readFileSync(backendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('DATABASE_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain all API keys', () => {
|
||||||
|
const content = readFileSync(backendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_ID');
|
||||||
|
expect(content).toContain('IGDB_CLIENT_SECRET');
|
||||||
|
expect(content).toContain('RAWG_API_KEY');
|
||||||
|
expect(content).toContain('THEGAMESDB_API_KEY');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Frontend .env.example', () => {
|
||||||
|
const frontendEnvPath = resolve(__dirname, '..', 'frontend', '.env.example');
|
||||||
|
|
||||||
|
it('should exist', () => {
|
||||||
|
expect(existsSync(frontendEnvPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain VITE_API_URL', () => {
|
||||||
|
const content = readFileSync(frontendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('VITE_API_URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain VITE_APP_NAME', () => {
|
||||||
|
const content = readFileSync(frontendEnvPath, 'utf-8');
|
||||||
|
expect(content).toContain('VITE_APP_NAME');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Git Ignore Configuration', () => {
|
||||||
|
it('should ignore .env files', () => {
|
||||||
|
const gitignorePath = resolve(__dirname, '..', '.gitignore');
|
||||||
|
const content = readFileSync(gitignorePath, 'utf-8');
|
||||||
|
expect(content).toContain('.env');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
tests/gitea-workflow.spec.ts
Normal file
126
tests/gitea-workflow.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
|
||||||
|
describe('Gitea Workflow CI - Phase 9.4', () => {
|
||||||
|
const workflowPath = resolve(process.cwd(), '.gitea/workflows/ci.yml');
|
||||||
|
const securityPath = resolve(process.cwd(), 'SECURITY.md');
|
||||||
|
const apiKeysPath = resolve(process.cwd(), 'docs/API_KEYS.md');
|
||||||
|
|
||||||
|
let workflowContent: string;
|
||||||
|
let workflowYaml: any;
|
||||||
|
let securityContent: string;
|
||||||
|
let apiKeysContent: string;
|
||||||
|
|
||||||
|
// Load files once
|
||||||
|
if (existsSync(workflowPath)) {
|
||||||
|
workflowContent = readFileSync(workflowPath, 'utf-8');
|
||||||
|
workflowYaml = parseYaml(workflowContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(securityPath)) {
|
||||||
|
securityContent = readFileSync(securityPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existsSync(apiKeysPath)) {
|
||||||
|
apiKeysContent = readFileSync(apiKeysPath, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 1: Workflow file exists
|
||||||
|
it('should have .gitea/workflows/ci.yml file', () => {
|
||||||
|
expect(existsSync(workflowPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Contains lint job
|
||||||
|
it('should contain job: lint', () => {
|
||||||
|
expect(workflowYaml?.jobs?.lint).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs.lint.steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Contains test-backend job
|
||||||
|
it('should contain job: test-backend', () => {
|
||||||
|
expect(workflowYaml?.jobs?.['test-backend']).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs['test-backend'].steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Contains test-frontend job
|
||||||
|
it('should contain job: test-frontend', () => {
|
||||||
|
expect(workflowYaml?.jobs?.['test-frontend']).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs['test-frontend'].steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Contains test-e2e job
|
||||||
|
it('should contain job: test-e2e', () => {
|
||||||
|
expect(workflowYaml?.jobs?.['test-e2e']).toBeDefined();
|
||||||
|
expect(workflowYaml.jobs['test-e2e'].steps).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 6: E2E job depends on backend and frontend tests
|
||||||
|
it('test-e2e should have needs: [test-backend, test-frontend]', () => {
|
||||||
|
const needs = workflowYaml?.jobs?.['test-e2e']?.needs;
|
||||||
|
expect(Array.isArray(needs) || typeof needs === 'string').toBe(true);
|
||||||
|
|
||||||
|
const needsArray = Array.isArray(needs) ? needs : [needs];
|
||||||
|
expect(needsArray).toContain('test-backend');
|
||||||
|
expect(needsArray).toContain('test-frontend');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 7: E2E uses Gitea Secrets for API keys
|
||||||
|
it('test-e2e should use Gitea Secrets for API keys', () => {
|
||||||
|
const env = workflowYaml?.jobs?.['test-e2e']?.env;
|
||||||
|
expect(env).toBeDefined();
|
||||||
|
|
||||||
|
// Check for secret references
|
||||||
|
const envStr = JSON.stringify(env);
|
||||||
|
expect(envStr).toContain('secrets.IGDB_CLIENT_ID');
|
||||||
|
expect(envStr).toContain('secrets.IGDB_CLIENT_SECRET');
|
||||||
|
expect(envStr).toContain('secrets.RAWG_API_KEY');
|
||||||
|
expect(envStr).toContain('secrets.THEGAMESDB_API_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 8: E2E includes yarn test:install step
|
||||||
|
it('test-e2e should include yarn test:install step', () => {
|
||||||
|
const steps = workflowYaml?.jobs?.['test-e2e']?.steps || [];
|
||||||
|
const testInstallStep = steps.some(
|
||||||
|
(step: any) =>
|
||||||
|
step.run && typeof step.run === 'string' && step.run.includes('yarn test:install')
|
||||||
|
);
|
||||||
|
expect(testInstallStep).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 9: Has triggers on push and pull_request
|
||||||
|
it('should have triggers on push and pull_request', () => {
|
||||||
|
const on = workflowYaml?.on;
|
||||||
|
expect(on).toBeDefined();
|
||||||
|
expect(on?.push || on?.['push']).toBeDefined();
|
||||||
|
expect(on?.pull_request || on?.['pull_request']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 10: Installs Node.js and caches yarn (lint job)
|
||||||
|
it('should install Node.js and cache yarn in lint job', () => {
|
||||||
|
const steps = workflowYaml?.jobs?.lint?.steps || [];
|
||||||
|
const hasNodeSetup = steps.some((step: any) => step.uses && step.uses.includes('setup-node'));
|
||||||
|
const hasYarnCache = steps.some(
|
||||||
|
(step: any) => step.uses && step.uses.includes('setup-node') && step.with?.cache === 'yarn'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasNodeSetup).toBe(true);
|
||||||
|
expect(hasYarnCache).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 11: SECURITY.md mentions Gitea Secrets setup
|
||||||
|
it('SECURITY.md should mention Gitea Secrets setup', () => {
|
||||||
|
expect(securityContent).toBeDefined();
|
||||||
|
expect(securityContent.toLowerCase()).toContain('gitea');
|
||||||
|
expect(securityContent.toLowerCase()).toContain('secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 12: SECURITY.md has instructions for CI/CD secrets
|
||||||
|
it('SECURITY.md should have CI/CD secrets instructions', () => {
|
||||||
|
expect(securityContent).toBeDefined();
|
||||||
|
expect(securityContent.toLowerCase()).toContain('ci/cd');
|
||||||
|
expect(securityContent).toContain('IGDB_CLIENT_ID');
|
||||||
|
expect(securityContent).toContain('RAWG_API_KEY');
|
||||||
|
});
|
||||||
|
});
|
||||||
78
tests/readme-validation.spec.ts
Normal file
78
tests/readme-validation.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe('README Validation - Phase 9.5', () => {
|
||||||
|
it('Test 1: README.md exists in root', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
expect(fs.existsSync(readmePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 2: README.md contains # Quasar heading', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/^# Quasar/m);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 3: README.md contains Features section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Features/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 4: README.md contains Quick Start section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Quick Start/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 5: README.md contains Installation subsection', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/### Installation/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 6: README.md contains Testing section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Testing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 7: README.md contains link to SECURITY.md', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/\[SECURITY\.md\]\(SECURITY\.md\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 8: README.md contains link to docs/API_KEYS.md', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/\[docs\/API_KEYS\.md\]\(docs\/API_KEYS\.md\)/) ||
|
||||||
|
expect(content).toMatch(/docs\/API_KEYS\.md/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 9: README.md contains Project Structure section', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Project Structure/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 10: README.md contains folder tree with backend/frontend', () => {
|
||||||
|
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||||
|
const content = fs.readFileSync(readmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/backend/);
|
||||||
|
expect(content).toMatch(/frontend/);
|
||||||
|
expect(content).toMatch(/tests/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11: frontend/README.md exists', () => {
|
||||||
|
const frontendReadmePath = path.join(__dirname, '..', 'frontend', 'README.md');
|
||||||
|
expect(fs.existsSync(frontendReadmePath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 12: frontend/README.md contains Setup instructions', () => {
|
||||||
|
const frontendReadmePath = path.join(__dirname, '..', 'frontend', 'README.md');
|
||||||
|
const content = fs.readFileSync(frontendReadmePath, 'utf-8');
|
||||||
|
expect(content).toMatch(/## Setup/) || expect(content).toMatch(/setup/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
testDir: 'tests',
|
||||||
|
include: ['**/*.spec.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user