Compare commits
3 Commits
fb1221701d
...
setup/ci
| Author | SHA1 | Date | |
|---|---|---|---|
| 5612df2e33 | |||
| a165ac9b65 | |||
| f61ebda9b7 |
186
.github/workflows/ci.yml
vendored
Normal file
186
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main, develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Job 1: Lint and Typecheck (Fast feedback)
|
||||||
|
lint-typecheck:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run Typecheck
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
# Job 2: Unit and Integration Tests
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-typecheck
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: npm run test:unit
|
||||||
|
|
||||||
|
- name: Run Integration Tests
|
||||||
|
run: npm run test:integration
|
||||||
|
|
||||||
|
# Job 3: Contract Tests (API/Website compatibility)
|
||||||
|
contract-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: lint-typecheck
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run API Contract Validation
|
||||||
|
run: npm run test:api:contracts
|
||||||
|
|
||||||
|
- name: Generate OpenAPI spec
|
||||||
|
run: npm run api:generate-spec
|
||||||
|
|
||||||
|
- name: Generate TypeScript types
|
||||||
|
run: npm run api:generate-types
|
||||||
|
|
||||||
|
- name: Run Contract Compatibility Check
|
||||||
|
run: npm run test:contract:compatibility
|
||||||
|
|
||||||
|
- name: Verify Website Type Checking
|
||||||
|
run: npm run website:type-check
|
||||||
|
|
||||||
|
- name: Upload generated types as artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: generated-types
|
||||||
|
path: apps/website/lib/types/generated/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Job 4: E2E Tests (Only on main/develop push, not on PRs)
|
||||||
|
e2e-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-typecheck, tests, contract-tests]
|
||||||
|
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run E2E Tests
|
||||||
|
run: npm run test:e2e
|
||||||
|
|
||||||
|
# Job 5: Comment PR with results (Only on PRs)
|
||||||
|
comment-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-typecheck, tests, contract-tests]
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Comment PR with results
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Read any contract change reports
|
||||||
|
const reportPath = path.join(process.cwd(), 'contract-report.json');
|
||||||
|
if (fs.existsSync(reportPath)) {
|
||||||
|
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||||
|
|
||||||
|
const comment = `
|
||||||
|
## 🔍 CI Results
|
||||||
|
|
||||||
|
✅ **All checks passed!**
|
||||||
|
|
||||||
|
### Changes Summary:
|
||||||
|
- Total changes: ${report.totalChanges}
|
||||||
|
- Breaking changes: ${report.breakingChanges}
|
||||||
|
- Added: ${report.added}
|
||||||
|
- Removed: ${report.removed}
|
||||||
|
- Modified: ${report.modified}
|
||||||
|
|
||||||
|
Generated types are available as artifacts.
|
||||||
|
`;
|
||||||
|
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: comment
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
# Job 6: Commit generated types (Only on main branch push)
|
||||||
|
commit-types:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-typecheck, tests, contract-tests]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Generate and snapshot types
|
||||||
|
run: |
|
||||||
|
npm run api:generate-spec
|
||||||
|
npm run api:generate-types
|
||||||
|
|
||||||
|
- name: Commit generated types
|
||||||
|
run: |
|
||||||
|
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config --local user.name "github-actions[bot]"
|
||||||
|
git add apps/website/lib/types/generated/
|
||||||
|
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
|
||||||
|
git push
|
||||||
110
.github/workflows/contract-testing.yml
vendored
110
.github/workflows/contract-testing.yml
vendored
@@ -1,110 +0,0 @@
|
|||||||
name: Contract Testing
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
contract-tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Run API contract validation
|
|
||||||
run: npm run test:api:contracts
|
|
||||||
|
|
||||||
- name: Generate OpenAPI spec
|
|
||||||
run: npm run api:generate-spec
|
|
||||||
|
|
||||||
- name: Generate TypeScript types
|
|
||||||
run: npm run api:generate-types
|
|
||||||
|
|
||||||
- name: Run contract compatibility check
|
|
||||||
run: npm run test:contract:compatibility
|
|
||||||
|
|
||||||
- name: Verify website type checking
|
|
||||||
run: npm run website:type-check
|
|
||||||
|
|
||||||
- name: Upload generated types as artifacts
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: generated-types
|
|
||||||
path: apps/website/lib/types/generated/
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
- name: Comment PR with results
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Read any contract change reports
|
|
||||||
const reportPath = path.join(process.cwd(), 'contract-report.json');
|
|
||||||
if (fs.existsSync(reportPath)) {
|
|
||||||
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
||||||
|
|
||||||
const comment = `
|
|
||||||
## 🔍 Contract Testing Results
|
|
||||||
|
|
||||||
✅ **All contract tests passed!**
|
|
||||||
|
|
||||||
### Changes Summary:
|
|
||||||
- Total changes: ${report.totalChanges}
|
|
||||||
- Breaking changes: ${report.breakingChanges}
|
|
||||||
- Added: ${report.added}
|
|
||||||
- Removed: ${report.removed}
|
|
||||||
- Modified: ${report.modified}
|
|
||||||
|
|
||||||
Generated types are available as artifacts.
|
|
||||||
`;
|
|
||||||
|
|
||||||
github.rest.issues.createComment({
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: comment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
contract-snapshot:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.ref == 'refs/heads/main'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
cache: 'npm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Generate and snapshot types
|
|
||||||
run: |
|
|
||||||
npm run api:generate-spec
|
|
||||||
npm run api:generate-types
|
|
||||||
|
|
||||||
- name: Commit generated types
|
|
||||||
run: |
|
|
||||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git config --local user.name "github-actions[bot]"
|
|
||||||
git add apps/website/lib/types/generated/
|
|
||||||
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
|
|
||||||
git push
|
|
||||||
@@ -1 +1 @@
|
|||||||
npm test
|
npx lint-staged
|
||||||
35
README.md
35
README.md
@@ -14,6 +14,17 @@ GridPilot streamlines the organization and administration of iRacing racing leag
|
|||||||
- **Docker** and **Docker Compose**
|
- **Docker** and **Docker Compose**
|
||||||
- Git for version control
|
- Git for version control
|
||||||
|
|
||||||
|
### Windows Compatibility
|
||||||
|
|
||||||
|
This project is fully compatible with Windows 11, macOS, and Linux. All development scripts have been updated to work across all platforms:
|
||||||
|
|
||||||
|
- ✅ Cross-platform npm scripts
|
||||||
|
- ✅ Windows-compatible Docker commands
|
||||||
|
- ✅ Universal test commands
|
||||||
|
- ✅ Cross-platform cleanup scripts
|
||||||
|
|
||||||
|
For detailed information, see [Windows Compatibility Guide](docs/WINDOWS_COMPATIBILITY.md).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -53,12 +64,28 @@ Individual applications support hot reload and watch mode during development:
|
|||||||
GridPilot follows strict BDD (Behavior-Driven Development) with comprehensive test coverage.
|
GridPilot follows strict BDD (Behavior-Driven Development) with comprehensive test coverage.
|
||||||
|
|
||||||
### Local Verification Pipeline
|
### Local Verification Pipeline
|
||||||
Run this sequence before pushing to ensure correctness:
|
|
||||||
```bash
|
GridPilot uses **lint-staged** to automatically validate only changed files on commit:
|
||||||
npm run lint && npm run typecheck && npm run test:unit && npm run test:integration
|
|
||||||
```
|
- `eslint --fix` runs on changed JS/TS/TSX files
|
||||||
|
- `vitest related --run` runs tests related to changed files
|
||||||
|
- `prettier --write` formats JSON, MD, and YAML files
|
||||||
|
|
||||||
|
This ensures fast commits without running the full test suite.
|
||||||
|
|
||||||
|
### Pre-Push Hook
|
||||||
|
|
||||||
|
A **pre-push hook** runs the full verification pipeline before pushing to remote:
|
||||||
|
|
||||||
|
- `npm run lint` - Check for linting errors
|
||||||
|
- `npm run typecheck` - Verify TypeScript types
|
||||||
|
- `npm run test:unit` - Run unit tests
|
||||||
|
- `npm run test:integration` - Run integration tests
|
||||||
|
|
||||||
|
You can skip this with `git push --no-verify` if needed.
|
||||||
|
|
||||||
### Individual Commands
|
### Individual Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
npm test
|
npm test
|
||||||
|
|||||||
55
package.json
55
package.json
@@ -45,6 +45,7 @@
|
|||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
"openapi-typescript": "^7.4.3",
|
"openapi-typescript": "^7.4.3",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^24.31.0",
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
|
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
|
||||||
"api:test": "vitest run --config vitest.api.config.ts",
|
"api:test": "vitest run --config vitest.api.config.ts",
|
||||||
"build": "echo 'Build all packages placeholder - to be configured'",
|
"build": "echo 'Build all packages placeholder - to be configured'",
|
||||||
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
|
"chrome:debug": "node -e \"console.log('Chrome debug: Open Chrome manually with --remote-debugging-port=9222')\"",
|
||||||
"companion:build": "npm run build --workspace=@gridpilot/companion",
|
"companion:build": "npm run build --workspace=@gridpilot/companion",
|
||||||
"companion:dev": "npm run dev --workspace=@gridpilot/companion",
|
"companion:dev": "npm run dev --workspace=@gridpilot/companion",
|
||||||
"companion:start": "npm run start --workspace=@gridpilot/companion",
|
"companion:start": "npm run start --workspace=@gridpilot/companion",
|
||||||
@@ -79,25 +80,17 @@
|
|||||||
"deploy:website:preview": "npx vercel deploy --cwd apps/website",
|
"deploy:website:preview": "npx vercel deploy --cwd apps/website",
|
||||||
"deploy:website:prod": "npx vercel deploy --prod",
|
"deploy:website:prod": "npx vercel deploy --prod",
|
||||||
"dev": "echo 'Development server placeholder - to be configured'",
|
"dev": "echo 'Development server placeholder - to be configured'",
|
||||||
"docker:dev": "sh -lc \"set -e; echo '[docker] Starting dev environment...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] Dev environment already running, attaching...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else echo '[docker] Starting fresh dev environment...'; COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up; fi\"",
|
"docker:dev": "node scripts/docker.js dev",
|
||||||
"docker:dev:build": "sh -lc \"set -e; echo '[docker] Building and starting dev environment...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] Stopping existing environment first...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml down --remove-orphans; fi; COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up --build\"",
|
"docker:dev:build": "node scripts/docker.js dev:build",
|
||||||
"docker:dev:clean": "sh -lc \"set -e; echo '[docker] Cleaning up dev environment...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml down -v --remove-orphans --volumes; echo '[docker] Cleanup complete'\"",
|
"docker:dev:clean": "node scripts/docker.js dev:clean",
|
||||||
"docker:dev:down": "sh -lc \"set -e; echo '[docker] Stopping dev environment...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml down --remove-orphans; echo '[docker] Stopped'\"",
|
"docker:dev:down": "node scripts/docker.js dev:down",
|
||||||
"docker:dev:force": "sh -lc \"set -e; echo '[docker] Force starting dev environment...'; echo '[docker] Stopping any existing environment...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml down --remove-orphans 2>/dev/null || true; echo '[docker] Starting fresh...'; COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up\"",
|
"docker:dev:inmemory": "cross-env GRIDPILOT_API_PERSISTENCE=inmemory npm run docker:dev:up",
|
||||||
"docker:dev:inmemory": "sh -lc \"GRIDPILOT_API_PERSISTENCE=inmemory npm run docker:dev:up\"",
|
"docker:dev:postgres": "cross-env GRIDPILOT_API_PERSISTENCE=postgres npm run docker:dev:up",
|
||||||
"docker:dev:logs": "sh -lc \"set -e; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else echo '[docker] No running containers to show logs for'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
"docker:dev:up": "node scripts/docker.js dev:up",
|
||||||
"docker:dev:postgres": "sh -lc \"GRIDPILOT_API_PERSISTENCE=postgres npm run docker:dev:up\"",
|
"docker:e2e:build": "node scripts/docker.js e2e:build",
|
||||||
"docker:dev:ps": "sh -lc \"set -e; echo '[docker] Container status:'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps; echo ''; echo '[docker] Running containers:'; docker ps --filter name=gridpilot-dev --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'\"",
|
"docker:e2e:clean": "node scripts/docker.js e2e:clean",
|
||||||
"docker:dev:reseed": "sh -lc \"set -e; echo '[docker] Reseeding with fresh database...'; echo '[docker] Stopping and removing volumes...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml down -v --remove-orphans; echo '[docker] Removing any legacy volumes...'; docker volume rm -f gridpilot_dev_db_data 2>/dev/null || true; echo '[docker] Starting fresh environment with force reseed...'; GRIDPILOT_API_FORCE_RESEED=true COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up\"",
|
"docker:e2e:down": "node scripts/docker.js e2e:down",
|
||||||
"docker:dev:restart": "sh -lc \"set -e; echo '[docker] Restarting services...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then docker-compose -p gridpilot-dev -f docker-compose.dev.yml restart; echo '[docker] Restarted'; else echo '[docker] No running containers to restart'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
"docker:e2e:up": "node scripts/docker.js e2e:up",
|
||||||
"docker:dev:status": "sh -lc \"set -e; echo '[docker] Checking dev environment status...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] ✓ Environment is RUNNING'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps; echo ''; echo '[docker] Services health:'; docker ps --filter name=gridpilot-dev --format 'table {{.Names}}\\t{{.Status}}\\t{{.RunningFor}}'; else echo '[docker] ✗ Environment is STOPPED'; echo '[docker] Start with: npm run docker:dev'; fi\"",
|
|
||||||
"docker:dev:up": "sh -lc \"set -e; echo '[docker] Starting dev environment...'; if docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps -q 2>/dev/null | grep -q .; then echo '[docker] Already running, attaching to logs...'; docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f; else COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up; fi\"",
|
|
||||||
"docker:e2e:build": "sh -lc \"echo '[e2e] Building website image...'; docker build -f apps/website/Dockerfile.e2e -t gridpilot-website-e2e . && echo '[e2e] Starting full stack...'; docker-compose -f docker-compose.e2e.yml up -d --build\"",
|
|
||||||
"docker:e2e:clean": "sh -lc \"echo '[e2e] Cleaning up...'; docker-compose -f docker-compose.e2e.yml down -v --remove-orphans; docker rmi gridpilot-website-e2e 2>/dev/null || true; echo '[e2e] Cleanup complete'\"",
|
|
||||||
"docker:e2e:down": "sh -lc \"echo '[e2e] Stopping e2e environment...'; docker-compose -f docker-compose.e2e.yml down --remove-orphans; echo '[e2e] Stopped'\"",
|
|
||||||
"docker:e2e:logs": "sh -lc \"docker-compose -f docker-compose.e2e.yml logs -f\"",
|
|
||||||
"docker:e2e:ps": "sh -lc \"docker-compose -f docker-compose.e2e.yml ps\"",
|
|
||||||
"docker:e2e:up": "sh -lc \"echo '[e2e] Starting full stack...'; docker-compose -f docker-compose.e2e.yml up -d --build\"",
|
|
||||||
"docker:prod": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d",
|
"docker:prod": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d",
|
||||||
"docker:prod:build": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d --build",
|
"docker:prod:build": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d --build",
|
||||||
"docker:prod:clean": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml down -v",
|
"docker:prod:clean": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml down -v",
|
||||||
@@ -115,17 +108,17 @@
|
|||||||
"prepare": "husky install || true",
|
"prepare": "husky install || true",
|
||||||
"smoke:website": "npm run website:build && npx playwright test -c playwright.website.config.ts",
|
"smoke:website": "npm run website:build && npx playwright test -c playwright.website.config.ts",
|
||||||
"smoke:website:docker": "npx playwright test -c playwright.website.config.ts",
|
"smoke:website:docker": "npx playwright test -c playwright.website.config.ts",
|
||||||
"test": "vitest run \"$@\"",
|
"test": "vitest run",
|
||||||
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
|
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
|
||||||
"test:api:smoke": "sh -lc \"echo '🚀 Running API smoke tests...'; npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",
|
"test:api:smoke": "npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html",
|
||||||
"test:api:smoke:docker": "sh -lc \"echo '🚀 Running API smoke tests in Docker...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html\"",
|
"test:api:smoke:docker": "docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test tests/e2e/api/api-smoke.test.ts --reporter=json,html",
|
||||||
"test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts",
|
"test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts",
|
||||||
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
|
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
|
||||||
"test:contracts": "tsx scripts/run-contract-tests.ts",
|
"test:contracts": "tsx scripts/run-contract-tests.ts",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
|
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
|
||||||
"test:e2e:run": "sh -lc \"npm run docker:e2e:up && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test\"",
|
"test:e2e:run": "node scripts/docker.js e2e:up && docker-compose -f docker-compose.e2e.yml run --rm playwright npx playwright test",
|
||||||
"test:e2e:website": "sh -lc \"set -e; trap 'npm run docker:e2e:down' EXIT; npm run docker:e2e:up && echo '[e2e] Waiting for services...'; sleep 10 && echo '[e2e] Running Playwright tests...'; docker-compose -f docker-compose.e2e.yml run --rm playwright\"",
|
"test:e2e:website": "node scripts/docker.js e2e:up && sleep 10 && docker-compose -f docker-compose.e2e.yml run --rm playwright",
|
||||||
"test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/",
|
"test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/",
|
||||||
"test:integration": "vitest run tests/integration",
|
"test:integration": "vitest run tests/integration",
|
||||||
"test:smoke": "vitest run --config vitest.smoke.config.ts",
|
"test:smoke": "vitest run --config vitest.smoke.config.ts",
|
||||||
@@ -136,8 +129,9 @@
|
|||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:watch": "vitest watch",
|
"test:watch": "vitest watch",
|
||||||
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
|
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
|
||||||
|
"verify": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration",
|
||||||
"typecheck": "npm run typecheck:targets",
|
"typecheck": "npm run typecheck:targets",
|
||||||
"typecheck:grep": "npm run typescript | grep",
|
"typecheck:grep": "npm run typescript",
|
||||||
"typecheck:root": "npx tsc --noEmit --project tsconfig.json",
|
"typecheck:root": "npx tsc --noEmit --project tsconfig.json",
|
||||||
"typecheck:targets": "npx tsc --noEmit -p apps/website/tsconfig.json && npx tsc --noEmit -p apps/api/tsconfig.json && npx tsc --noEmit -p adapters/tsconfig.json && npx tsc --noEmit -p core/tsconfig.json",
|
"typecheck:targets": "npx tsc --noEmit -p apps/website/tsconfig.json && npx tsc --noEmit -p apps/api/tsconfig.json && npx tsc --noEmit -p adapters/tsconfig.json && npx tsc --noEmit -p core/tsconfig.json",
|
||||||
"website:build": "npm run env:website:merge && npm run build --workspace=@gridpilot/website",
|
"website:build": "npm run env:website:merge && npm run build --workspace=@gridpilot/website",
|
||||||
@@ -147,6 +141,15 @@
|
|||||||
"website:start": "npm run start --workspace=@gridpilot/website",
|
"website:start": "npm run start --workspace=@gridpilot/website",
|
||||||
"website:type-check": "npm run type-check --workspace=@gridpilot/website"
|
"website:type-check": "npm run type-check --workspace=@gridpilot/website"
|
||||||
},
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,tsx}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"vitest related --run"
|
||||||
|
],
|
||||||
|
"*.{json,md,yml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"core/*",
|
"core/*",
|
||||||
|
|||||||
100
plans/ci-optimization.md
Normal file
100
plans/ci-optimization.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# CI/CD & Dev Experience Optimization Plan
|
||||||
|
|
||||||
|
## Current Situation
|
||||||
|
|
||||||
|
- **Husky `pre-commit`**: Runs `npm test` (Vitest) on every commit. This likely runs the entire test suite, which is slow and frustrating for developers.
|
||||||
|
- **Gitea Actions**: Currently only has `contract-testing.yml`.
|
||||||
|
- **Missing**: No automated linting or type-checking in CI, no tiered testing strategy.
|
||||||
|
|
||||||
|
## Proposed Strategy: The "Fast Feedback Loop"
|
||||||
|
|
||||||
|
We will implement a tiered approach to balance speed and safety.
|
||||||
|
|
||||||
|
### 1. Local Development (Husky + lint-staged)
|
||||||
|
|
||||||
|
**Goal**: Prevent obvious errors from entering the repo without slowing down the dev.
|
||||||
|
|
||||||
|
- **Trigger**: `pre-commit`
|
||||||
|
- **Action**: Only run on **staged files**.
|
||||||
|
- **Tasks**:
|
||||||
|
- `eslint --fix`
|
||||||
|
- `prettier --write`
|
||||||
|
- `vitest related` (only run tests related to changed files)
|
||||||
|
|
||||||
|
### 2. Pull Request (Gitea Actions)
|
||||||
|
|
||||||
|
**Goal**: Ensure the branch is stable and doesn't break the build or other modules.
|
||||||
|
|
||||||
|
- **Trigger**: PR creation and updates.
|
||||||
|
- **Tasks**:
|
||||||
|
- Full `lint`
|
||||||
|
- Full `typecheck` (crucial for monorepo integrity)
|
||||||
|
- Full `unit tests`
|
||||||
|
- `integration tests`
|
||||||
|
- `contract tests`
|
||||||
|
|
||||||
|
### 3. Merge to Main / Release (Gitea Actions)
|
||||||
|
|
||||||
|
**Goal**: Final verification before deployment.
|
||||||
|
|
||||||
|
- **Trigger**: Push to `main` or `develop`.
|
||||||
|
- **Tasks**:
|
||||||
|
- Everything from PR stage.
|
||||||
|
- `e2e tests` (Playwright) - these are the slowest and most expensive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Install and Configure `lint-staged`
|
||||||
|
|
||||||
|
We need to add `lint-staged` to [`package.json`](package.json) and update the Husky hook.
|
||||||
|
|
||||||
|
### Step 2: Optimize Husky Hook
|
||||||
|
|
||||||
|
Update [`.husky/pre-commit`](.husky/pre-commit) to run `npx lint-staged` instead of `npm test`.
|
||||||
|
|
||||||
|
### Step 3: Create Comprehensive CI Workflow
|
||||||
|
|
||||||
|
Create `.github/workflows/ci.yml` (Gitea Actions compatible) to handle the heavy lifting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Developer Commits] --> B{Husky pre-commit}
|
||||||
|
B -->|lint-staged| C[Lint/Format Changed Files]
|
||||||
|
C --> D[Run Related Tests]
|
||||||
|
D --> E[Commit Success]
|
||||||
|
|
||||||
|
E --> F[Push to PR]
|
||||||
|
F --> G{Gitea CI PR Job}
|
||||||
|
G --> H[Full Lint & Typecheck]
|
||||||
|
G --> I[Full Unit & Integration Tests]
|
||||||
|
G --> J[Contract Tests]
|
||||||
|
|
||||||
|
J --> K{Merge to Main}
|
||||||
|
K --> L{Gitea CI Main Job}
|
||||||
|
L --> M[All PR Checks]
|
||||||
|
L --> N[Full E2E Tests]
|
||||||
|
N --> O[Deploy/Release]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Proposed `lint-staged` Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"*.{js,ts,tsx}": ["eslint --fix", "vitest related --run"],
|
||||||
|
"*.{json,md,yml}": ["prettier --write"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions for the User
|
||||||
|
|
||||||
|
1. Do you want to include `typecheck` in the `pre-commit` hook? (Note: `tsc` doesn't support linting only changed files easily, so it usually checks the whole project, which might be slow).
|
||||||
|
2. Should we run `integration tests` on every PR, or only on merge to `main`?
|
||||||
|
3. Are there specific directories that should be excluded from this automated flow?
|
||||||
51
scripts/docker.js
Normal file
51
scripts/docker.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
function isWindows() {
|
||||||
|
return os.platform() === 'win32';
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command) {
|
||||||
|
try {
|
||||||
|
execSync(command, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: isWindows() ? 'cmd.exe' : 'sh'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
console.error('Usage: node scripts/docker.js <command>');
|
||||||
|
console.error('Available commands: dev, dev:build, dev:clean, dev:down, dev:up, e2e:build, e2e:clean, e2e:down, e2e:up');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = {
|
||||||
|
'dev': 'docker-compose -p gridpilot-dev -f docker-compose.dev.yml up',
|
||||||
|
'dev:build': 'docker-compose -p gridpilot-dev -f docker-compose.dev.yml up --build',
|
||||||
|
'dev:clean': 'docker-compose -p gridpilot-dev -f docker-compose.dev.yml down -v --remove-orphans --volumes',
|
||||||
|
'dev:down': 'docker-compose -p gridpilot-dev -f docker-compose.dev.yml down --remove-orphans',
|
||||||
|
'dev:up': 'docker-compose -p gridpilot-dev -f docker-compose.dev.yml up',
|
||||||
|
'e2e:build': 'docker build -f apps/website/Dockerfile.e2e -t gridpilot-website-e2e . && docker-compose -f docker-compose.e2e.yml up -d --build',
|
||||||
|
'e2e:clean': 'docker-compose -f docker-compose.e2e.yml down -v --remove-orphans && docker rmi gridpilot-website-e2e 2>/dev/null || true',
|
||||||
|
'e2e:down': 'docker-compose -f docker-compose.e2e.yml down --remove-orphans',
|
||||||
|
'e2e:up': 'docker-compose -f docker-compose.e2e.yml up -d --build'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!commands[command]) {
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommand(commands[command]);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Test: League Members Data Flow
|
|
||||||
*
|
|
||||||
* Tests the complete data flow from database to API response for league members:
|
|
||||||
* 1. Database query returns correct data
|
|
||||||
* 2. Use case processes the data correctly
|
|
||||||
* 3. Presenter transforms data to DTOs
|
|
||||||
* 4. API returns correct response
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
||||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
|
||||||
|
|
||||||
describe('League Members - Data Flow Integration', () => {
|
|
||||||
let harness: IntegrationTestHarness;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
harness = createTestHarness();
|
|
||||||
await harness.beforeAll();
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await harness.afterAll();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await harness.beforeEach();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API to View Data Flow', () => {
|
|
||||||
it('should return correct members DTO structure from API', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Members Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create drivers
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Owner Driver', country: 'US' }),
|
|
||||||
factory.createDriver({ name: 'Admin Driver', country: 'UK' }),
|
|
||||||
factory.createDriver({ name: 'Member Driver', country: 'CA' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create league memberships (simulated via database)
|
|
||||||
// Note: In real implementation, memberships would be created through the domain
|
|
||||||
// For this test, we'll verify the API response structure
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Verify: API response structure
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.memberships).toBeDefined();
|
|
||||||
expect(Array.isArray(response.memberships)).toBe(true);
|
|
||||||
|
|
||||||
// Verify: Each membership has correct DTO structure
|
|
||||||
for (const membership of response.memberships) {
|
|
||||||
expect(membership).toHaveProperty('driverId');
|
|
||||||
expect(membership).toHaveProperty('driver');
|
|
||||||
expect(membership).toHaveProperty('role');
|
|
||||||
expect(membership).toHaveProperty('status');
|
|
||||||
expect(membership).toHaveProperty('joinedAt');
|
|
||||||
|
|
||||||
// Verify driver DTO structure
|
|
||||||
expect(membership.driver).toHaveProperty('id');
|
|
||||||
expect(membership.driver).toHaveProperty('iracingId');
|
|
||||||
expect(membership.driver).toHaveProperty('name');
|
|
||||||
expect(membership.driver).toHaveProperty('country');
|
|
||||||
expect(membership.driver).toHaveProperty('joinedAt');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty members for league with no members', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Empty Members League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
expect(response.memberships).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle league with single member', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Single Member League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Solo Member', country: 'US' });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Should have at least the owner
|
|
||||||
expect(response.memberships.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const soloMember = response.memberships.find(m => m.driver.name === 'Solo Member');
|
|
||||||
expect(soloMember).toBeDefined();
|
|
||||||
expect(soloMember?.role).toBeDefined();
|
|
||||||
expect(soloMember?.status).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('End-to-End Data Flow', () => {
|
|
||||||
it('should correctly transform member data to DTO', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Transformation Members League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Owner', country: 'US', iracingId: '1001' }),
|
|
||||||
factory.createDriver({ name: 'Admin', country: 'UK', iracingId: '1002' }),
|
|
||||||
factory.createDriver({ name: 'Member', country: 'CA', iracingId: '1003' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Verify all drivers are in the response
|
|
||||||
expect(response.memberships.length).toBeGreaterThanOrEqual(3);
|
|
||||||
|
|
||||||
// Verify each driver has correct data
|
|
||||||
for (const driver of drivers) {
|
|
||||||
const membership = response.memberships.find(m => m.driver.name === driver.name.toString());
|
|
||||||
expect(membership).toBeDefined();
|
|
||||||
expect(membership?.driver.id).toBe(driver.id.toString());
|
|
||||||
expect(membership?.driver.iracingId).toBe(driver.iracingId);
|
|
||||||
expect(membership?.driver.country).toBe(driver.country);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle league with many members', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Many Members League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create 15 drivers
|
|
||||||
const drivers = await Promise.all(
|
|
||||||
Array.from({ length: 15 }, (_, i) =>
|
|
||||||
factory.createDriver({ name: `Member ${i + 1}`, iracingId: `${2000 + i}` })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Should have all drivers
|
|
||||||
expect(response.memberships.length).toBeGreaterThanOrEqual(15);
|
|
||||||
|
|
||||||
// All memberships should have correct structure
|
|
||||||
for (const membership of response.memberships) {
|
|
||||||
expect(membership).toHaveProperty('driverId');
|
|
||||||
expect(membership).toHaveProperty('driver');
|
|
||||||
expect(membership).toHaveProperty('role');
|
|
||||||
expect(membership).toHaveProperty('status');
|
|
||||||
expect(membership).toHaveProperty('joinedAt');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle members with different roles', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Roles League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Owner', country: 'US' }),
|
|
||||||
factory.createDriver({ name: 'Admin', country: 'UK' }),
|
|
||||||
factory.createDriver({ name: 'Member', country: 'CA' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Should have members with different roles
|
|
||||||
const roles = response.memberships.map(m => m.role);
|
|
||||||
expect(roles.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// Verify roles are present
|
|
||||||
const hasOwner = roles.some(r => r === 'owner' || r === 'OWNER');
|
|
||||||
const hasAdmin = roles.some(r => r === 'admin' || r === 'ADMIN');
|
|
||||||
const hasMember = roles.some(r => r === 'member' || r === 'MEMBER');
|
|
||||||
|
|
||||||
// At least owner should exist
|
|
||||||
expect(hasOwner || hasAdmin || hasMember).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Data Consistency', () => {
|
|
||||||
it('should maintain data consistency across multiple API calls', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Consistency Members League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Consistent Member', country: 'DE' });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
|
|
||||||
// Make multiple calls
|
|
||||||
const response1 = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
const response2 = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
const response3 = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// All responses should be identical
|
|
||||||
expect(response1).toEqual(response2);
|
|
||||||
expect(response2).toEqual(response3);
|
|
||||||
|
|
||||||
// Verify data integrity
|
|
||||||
expect(response1.memberships.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const consistentMember = response1.memberships.find(m => m.driver.name === 'Consistent Member');
|
|
||||||
expect(consistentMember).toBeDefined();
|
|
||||||
expect(consistentMember?.driver.country).toBe('DE');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with many members and complex data', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Complex Members League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create 20 drivers
|
|
||||||
const drivers = await Promise.all(
|
|
||||||
Array.from({ length: 20 }, (_, i) =>
|
|
||||||
factory.createDriver({
|
|
||||||
name: `Complex Member ${i + 1}`,
|
|
||||||
iracingId: `${3000 + i}`,
|
|
||||||
country: ['US', 'UK', 'CA', 'DE', 'FR'][i % 5]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Should have all drivers
|
|
||||||
expect(response.memberships.length).toBeGreaterThanOrEqual(20);
|
|
||||||
|
|
||||||
// All memberships should have correct structure
|
|
||||||
for (const membership of response.memberships) {
|
|
||||||
expect(membership).toHaveProperty('driverId');
|
|
||||||
expect(membership).toHaveProperty('driver');
|
|
||||||
expect(membership).toHaveProperty('role');
|
|
||||||
expect(membership).toHaveProperty('status');
|
|
||||||
expect(membership).toHaveProperty('joinedAt');
|
|
||||||
|
|
||||||
// Verify driver has all required fields
|
|
||||||
expect(membership.driver).toHaveProperty('id');
|
|
||||||
expect(membership.driver).toHaveProperty('iracingId');
|
|
||||||
expect(membership.driver).toHaveProperty('name');
|
|
||||||
expect(membership.driver).toHaveProperty('country');
|
|
||||||
expect(membership.driver).toHaveProperty('joinedAt');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify all drivers are present
|
|
||||||
const driverNames = response.memberships.map(m => m.driver.name);
|
|
||||||
for (const driver of drivers) {
|
|
||||||
expect(driverNames).toContain(driver.name.toString());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: members with optional fields', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Optional Fields League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create driver without bio (should be optional)
|
|
||||||
const driver = await factory.createDriver({ name: 'Test Member', country: 'US' });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
expect(response.memberships.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const testMember = response.memberships.find(m => m.driver.name === 'Test Member');
|
|
||||||
expect(testMember).toBeDefined();
|
|
||||||
expect(testMember?.driver.bio).toBeUndefined(); // Optional field
|
|
||||||
expect(testMember?.driver.name).toBe('Test Member');
|
|
||||||
expect(testMember?.driver.country).toBe('US');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with no completed races but has members', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'No Races Members League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Waiting Member', country: 'US' });
|
|
||||||
|
|
||||||
// Create only scheduled races (no completed races)
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Future Track',
|
|
||||||
car: 'Future Car',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
|
||||||
|
|
||||||
// Should still have members even with no completed races
|
|
||||||
expect(response.memberships.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const waitingMember = response.memberships.find(m => m.driver.name === 'Waiting Member');
|
|
||||||
expect(waitingMember).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Test: League Schedule Data Flow
|
|
||||||
*
|
|
||||||
* Tests the complete data flow from database to API response for league schedule:
|
|
||||||
* 1. Database query returns correct data
|
|
||||||
* 2. Use case processes the data correctly
|
|
||||||
* 3. Presenter transforms data to DTOs
|
|
||||||
* 4. API returns correct response
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
||||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
|
||||||
|
|
||||||
describe('League Schedule - Data Flow Integration', () => {
|
|
||||||
let harness: IntegrationTestHarness;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
harness = createTestHarness();
|
|
||||||
await harness.beforeAll();
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await harness.afterAll();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await harness.beforeEach();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API to View Data Flow', () => {
|
|
||||||
it('should return correct schedule DTO structure from API', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Schedule Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create races with different statuses
|
|
||||||
const race1 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Future race
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const race2 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Road Atlanta',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // Future race
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const race3 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Nürburgring',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Past race
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
// Verify: API response structure
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.races).toBeDefined();
|
|
||||||
expect(Array.isArray(response.races)).toBe(true);
|
|
||||||
|
|
||||||
// Verify: Each race has correct DTO structure
|
|
||||||
for (const race of response.races) {
|
|
||||||
expect(race).toHaveProperty('id');
|
|
||||||
expect(race).toHaveProperty('track');
|
|
||||||
expect(race).toHaveProperty('car');
|
|
||||||
expect(race).toHaveProperty('scheduledAt');
|
|
||||||
expect(race).toHaveProperty('status');
|
|
||||||
expect(race).toHaveProperty('results');
|
|
||||||
expect(Array.isArray(race.results)).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify: Race data matches what we created
|
|
||||||
const scheduledRaces = response.races.filter(r => r.status === 'scheduled');
|
|
||||||
const completedRaces = response.races.filter(r => r.status === 'completed');
|
|
||||||
|
|
||||||
expect(scheduledRaces).toHaveLength(2);
|
|
||||||
expect(completedRaces).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty schedule for league with no races', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Empty Schedule League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
expect(response.races).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle schedule with single race', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Single Race League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Monza',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
expect(response.races).toHaveLength(1);
|
|
||||||
expect(response.races[0].track).toBe('Monza');
|
|
||||||
expect(response.races[0].car).toBe('GT3');
|
|
||||||
expect(response.races[0].status).toBe('scheduled');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('End-to-End Data Flow', () => {
|
|
||||||
it('should correctly transform race data to schedule DTO', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Transformation Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Test Driver', country: 'US' });
|
|
||||||
|
|
||||||
// Create a completed race with results
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Suzuka',
|
|
||||||
car: 'Formula 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
await factory.createResult(race.id.toString(), driver.id.toString(), {
|
|
||||||
position: 1,
|
|
||||||
fastestLap: 92000,
|
|
||||||
incidents: 0,
|
|
||||||
startPosition: 2
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
expect(response.races).toHaveLength(1);
|
|
||||||
|
|
||||||
const raceData = response.races[0];
|
|
||||||
expect(raceData.track).toBe('Suzuka');
|
|
||||||
expect(raceData.car).toBe('Formula 1');
|
|
||||||
expect(raceData.status).toBe('completed');
|
|
||||||
expect(raceData.results).toHaveLength(1);
|
|
||||||
expect(raceData.results[0].position).toBe(1);
|
|
||||||
expect(raceData.results[0].driverId).toBe(driver.id.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle schedule with multiple races and results', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Multi Race League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Driver 1', country: 'US' }),
|
|
||||||
factory.createDriver({ name: 'Driver 2', country: 'UK' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create 3 races
|
|
||||||
const races = await Promise.all([
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track 1',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track 2',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track 3',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add results to first two races
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
|
||||||
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
expect(response.races).toHaveLength(3);
|
|
||||||
|
|
||||||
// Verify completed races have results
|
|
||||||
const completedRaces = response.races.filter(r => r.status === 'completed');
|
|
||||||
expect(completedRaces).toHaveLength(2);
|
|
||||||
|
|
||||||
for (const race of completedRaces) {
|
|
||||||
expect(race.results).toHaveLength(2);
|
|
||||||
expect(race.results[0].position).toBeDefined();
|
|
||||||
expect(race.results[0].driverId).toBeDefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify scheduled race has no results
|
|
||||||
const scheduledRace = response.races.find(r => r.status === 'scheduled');
|
|
||||||
expect(scheduledRace).toBeDefined();
|
|
||||||
expect(scheduledRace?.results).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle schedule with published/unpublished races', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Publish Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create races with different publish states
|
|
||||||
const race1 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track A',
|
|
||||||
car: 'Car A',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const race2 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track B',
|
|
||||||
car: 'Car B',
|
|
||||||
scheduledAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
expect(response.races).toHaveLength(2);
|
|
||||||
|
|
||||||
// Both races should be in the schedule
|
|
||||||
const trackNames = response.races.map(r => r.track);
|
|
||||||
expect(trackNames).toContain('Track A');
|
|
||||||
expect(trackNames).toContain('Track B');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Data Consistency', () => {
|
|
||||||
it('should maintain data consistency across multiple API calls', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Consistency Schedule League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Consistency Track',
|
|
||||||
car: 'Consistency Car',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
|
|
||||||
// Make multiple calls
|
|
||||||
const response1 = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
const response2 = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
const response3 = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
// All responses should be identical
|
|
||||||
expect(response1).toEqual(response2);
|
|
||||||
expect(response2).toEqual(response3);
|
|
||||||
|
|
||||||
// Verify data integrity
|
|
||||||
expect(response1.races).toHaveLength(1);
|
|
||||||
expect(response1.races[0].track).toBe('Consistency Track');
|
|
||||||
expect(response1.races[0].car).toBe('Consistency Car');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with many races', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Large Schedule League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create 20 races
|
|
||||||
const races = await Promise.all(
|
|
||||||
Array.from({ length: 20 }, (_, i) =>
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: `Track ${i + 1}`,
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() + (i + 1) * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
// Should have all 20 races
|
|
||||||
expect(response.races).toHaveLength(20);
|
|
||||||
|
|
||||||
// All races should have correct structure
|
|
||||||
for (const race of response.races) {
|
|
||||||
expect(race).toHaveProperty('id');
|
|
||||||
expect(race).toHaveProperty('track');
|
|
||||||
expect(race).toHaveProperty('car');
|
|
||||||
expect(race).toHaveProperty('scheduledAt');
|
|
||||||
expect(race).toHaveProperty('status');
|
|
||||||
expect(race).toHaveProperty('results');
|
|
||||||
expect(Array.isArray(race.results)).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// All races should be scheduled
|
|
||||||
const allScheduled = response.races.every(r => r.status === 'scheduled');
|
|
||||||
expect(allScheduled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with races spanning multiple seasons', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Multi Season League' });
|
|
||||||
|
|
||||||
// Create two seasons
|
|
||||||
const season1 = await factory.createSeason(league.id.toString(), { name: 'Season 1', year: 2024 });
|
|
||||||
const season2 = await factory.createSeason(league.id.toString(), { name: 'Season 2', year: 2025 });
|
|
||||||
|
|
||||||
// Create races in both seasons
|
|
||||||
const race1 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Season 1 Track',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), // Last year
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
const race2 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Season 2 Track',
|
|
||||||
car: 'Car 2',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // This year
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
// Should have both races (schedule endpoint returns all races for league)
|
|
||||||
expect(response.races).toHaveLength(2);
|
|
||||||
|
|
||||||
const trackNames = response.races.map(r => r.track);
|
|
||||||
expect(trackNames).toContain('Season 1 Track');
|
|
||||||
expect(trackNames).toContain('Season 2 Track');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: race with no results', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'No Results League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create a completed race with no results
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Empty Results Track',
|
|
||||||
car: 'Empty Car',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
|
||||||
|
|
||||||
expect(response.races).toHaveLength(1);
|
|
||||||
expect(response.races[0].results).toEqual([]);
|
|
||||||
expect(response.races[0].status).toBe('completed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Test: League Schedule Lifecycle API
|
|
||||||
*
|
|
||||||
* Tests publish/unpublish/republish lifecycle endpoints.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { ApiClient } from '../harness/api-client';
|
|
||||||
import { DockerManager } from '../harness/docker-manager';
|
|
||||||
|
|
||||||
describe('League Schedule Lifecycle - API Integration', () => {
|
|
||||||
let api: ApiClient;
|
|
||||||
let docker: DockerManager;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
docker = DockerManager.getInstance();
|
|
||||||
await docker.start();
|
|
||||||
|
|
||||||
api = new ApiClient({ baseUrl: 'http://localhost:3101', timeout: 60000 });
|
|
||||||
await api.waitForReady();
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
docker.stop();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it('should handle publish endpoint for non-existent league', async () => {
|
|
||||||
const nonExistentLeagueId = 'non-existent-league';
|
|
||||||
const nonExistentSeasonId = 'non-existent-season';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/leagues/${nonExistentLeagueId}/seasons/${nonExistentSeasonId}/publish`, {})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unpublish endpoint for non-existent league', async () => {
|
|
||||||
const nonExistentLeagueId = 'non-existent-league';
|
|
||||||
const nonExistentSeasonId = 'non-existent-season';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/leagues/${nonExistentLeagueId}/seasons/${nonExistentSeasonId}/unpublish`, {})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle create schedule race endpoint for non-existent league', async () => {
|
|
||||||
const nonExistentLeagueId = 'non-existent-league';
|
|
||||||
const nonExistentSeasonId = 'non-existent-season';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/leagues/${nonExistentLeagueId}/seasons/${nonExistentSeasonId}/schedule/races`, {
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAtIso: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid date format', async () => {
|
|
||||||
const leagueId = 'test-league';
|
|
||||||
const seasonId = 'test-season';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/leagues/${leagueId}/seasons/${seasonId}/schedule/races`, {
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAtIso: 'invalid-date',
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject missing required fields for race creation', async () => {
|
|
||||||
const leagueId = 'test-league';
|
|
||||||
const seasonId = 'test-season';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/leagues/${leagueId}/seasons/${seasonId}/schedule/races`, {
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
// Missing car and scheduledAtIso
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Test: League Standings Data Flow
|
|
||||||
*
|
|
||||||
* Tests the complete data flow from database to API response for league standings:
|
|
||||||
* 1. Database query returns correct data
|
|
||||||
* 2. Use case processes the data correctly
|
|
||||||
* 3. Presenter transforms data to DTOs
|
|
||||||
* 4. API returns correct response
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
||||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
|
||||||
|
|
||||||
describe('League Standings - Data Flow Integration', () => {
|
|
||||||
let harness: IntegrationTestHarness;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
harness = createTestHarness();
|
|
||||||
await harness.beforeAll();
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await harness.afterAll();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await harness.beforeEach();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API to View Data Flow', () => {
|
|
||||||
it('should return correct standings DTO structure from API', async () => {
|
|
||||||
// Setup: Create test data
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create drivers
|
|
||||||
const driver1 = await factory.createDriver({ name: 'John Doe', country: 'US' });
|
|
||||||
const driver2 = await factory.createDriver({ name: 'Jane Smith', country: 'UK' });
|
|
||||||
const driver3 = await factory.createDriver({ name: 'Bob Johnson', country: 'CA' });
|
|
||||||
|
|
||||||
// Create races with results
|
|
||||||
const race1 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Past race
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
const race2 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Road Atlanta',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Past race
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create results for race 1
|
|
||||||
await factory.createResult(race1.id.toString(), driver1.id.toString(), { position: 1, fastestLap: 95000, incidents: 0, startPosition: 2 });
|
|
||||||
await factory.createResult(race1.id.toString(), driver2.id.toString(), { position: 2, fastestLap: 95500, incidents: 1, startPosition: 1 });
|
|
||||||
await factory.createResult(race1.id.toString(), driver3.id.toString(), { position: 3, fastestLap: 96000, incidents: 2, startPosition: 3 });
|
|
||||||
|
|
||||||
// Create results for race 2
|
|
||||||
await factory.createResult(race2.id.toString(), driver1.id.toString(), { position: 2, fastestLap: 94500, incidents: 1, startPosition: 1 });
|
|
||||||
await factory.createResult(race2.id.toString(), driver2.id.toString(), { position: 1, fastestLap: 94000, incidents: 0, startPosition: 3 });
|
|
||||||
await factory.createResult(race2.id.toString(), driver3.id.toString(), { position: 3, fastestLap: 95000, incidents: 1, startPosition: 2 });
|
|
||||||
|
|
||||||
// Execute: Call API endpoint
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
// Verify: API response structure
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.standings).toBeDefined();
|
|
||||||
expect(Array.isArray(response.standings)).toBe(true);
|
|
||||||
|
|
||||||
// Verify: Each standing has correct DTO structure
|
|
||||||
for (const standing of response.standings) {
|
|
||||||
expect(standing).toHaveProperty('driverId');
|
|
||||||
expect(standing).toHaveProperty('driver');
|
|
||||||
expect(standing).toHaveProperty('points');
|
|
||||||
expect(standing).toHaveProperty('position');
|
|
||||||
expect(standing).toHaveProperty('wins');
|
|
||||||
expect(standing).toHaveProperty('podiums');
|
|
||||||
expect(standing).toHaveProperty('races');
|
|
||||||
expect(standing).toHaveProperty('positionChange');
|
|
||||||
expect(standing).toHaveProperty('lastRacePoints');
|
|
||||||
expect(standing).toHaveProperty('droppedRaceIds');
|
|
||||||
|
|
||||||
// Verify driver DTO structure
|
|
||||||
expect(standing.driver).toHaveProperty('id');
|
|
||||||
expect(standing.driver).toHaveProperty('iracingId');
|
|
||||||
expect(standing.driver).toHaveProperty('name');
|
|
||||||
expect(standing.driver).toHaveProperty('country');
|
|
||||||
expect(standing.driver).toHaveProperty('joinedAt');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty standings for league with no results', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Empty League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
expect(response.standings).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle standings with single driver', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Single Driver League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Solo Driver', country: 'US' });
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 95000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
expect(response.standings).toHaveLength(1);
|
|
||||||
expect(response.standings[0].driver.name).toBe('Solo Driver');
|
|
||||||
expect(response.standings[0].position).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('End-to-End Data Flow', () => {
|
|
||||||
it('should correctly calculate standings from race results', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Calculation Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create 3 drivers
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Driver A', iracingId: '1001' }),
|
|
||||||
factory.createDriver({ name: 'Driver B', iracingId: '1002' }),
|
|
||||||
factory.createDriver({ name: 'Driver C', iracingId: '1003' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create 5 races
|
|
||||||
const races = await Promise.all([
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 1', car: 'Car 1', scheduledAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 2', car: 'Car 1', scheduledAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 3', car: 'Car 1', scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 4', car: 'Car 1', scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 5', car: 'Car 1', scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create results with specific points to verify calculation
|
|
||||||
// Standard scoring: 1st=25, 2nd=18, 3rd=15
|
|
||||||
// Race 1: A=1st, B=2nd, C=3rd
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 91000, incidents: 2, startPosition: 3 });
|
|
||||||
|
|
||||||
// Race 2: B=1st, C=2nd, A=3rd
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89500, incidents: 0, startPosition: 3 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[2].id.toString(), { position: 2, fastestLap: 90000, incidents: 1, startPosition: 1 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 90500, incidents: 2, startPosition: 2 });
|
|
||||||
|
|
||||||
// Race 3: C=1st, A=2nd, B=3rd
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[2].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 2 });
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 3 });
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[1].id.toString(), { position: 3, fastestLap: 90000, incidents: 2, startPosition: 1 });
|
|
||||||
|
|
||||||
// Race 4: A=1st, B=2nd, C=3rd
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 88500, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 89000, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 89500, incidents: 2, startPosition: 3 });
|
|
||||||
|
|
||||||
// Race 5: B=1st, C=2nd, A=3rd
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 88000, incidents: 0, startPosition: 3 });
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[2].id.toString(), { position: 2, fastestLap: 88500, incidents: 1, startPosition: 1 });
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 89000, incidents: 2, startPosition: 2 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
// Expected points:
|
|
||||||
// Driver A: 25 + 15 + 18 + 25 + 15 = 98
|
|
||||||
// Driver B: 18 + 25 + 15 + 18 + 25 = 101
|
|
||||||
// Driver C: 15 + 18 + 25 + 15 + 18 = 91
|
|
||||||
|
|
||||||
expect(response.standings).toHaveLength(3);
|
|
||||||
|
|
||||||
// Find drivers in response
|
|
||||||
const standingA = response.standings.find(s => s.driver.name === 'Driver A');
|
|
||||||
const standingB = response.standings.find(s => s.driver.name === 'Driver B');
|
|
||||||
const standingC = response.standings.find(s => s.driver.name === 'Driver C');
|
|
||||||
|
|
||||||
expect(standingA).toBeDefined();
|
|
||||||
expect(standingB).toBeDefined();
|
|
||||||
expect(standingC).toBeDefined();
|
|
||||||
|
|
||||||
// Verify positions (B should be 1st, A 2nd, C 3rd)
|
|
||||||
expect(standingB?.position).toBe(1);
|
|
||||||
expect(standingA?.position).toBe(2);
|
|
||||||
expect(standingC?.position).toBe(3);
|
|
||||||
|
|
||||||
// Verify race counts
|
|
||||||
expect(standingA?.races).toBe(5);
|
|
||||||
expect(standingB?.races).toBe(5);
|
|
||||||
expect(standingC?.races).toBe(5);
|
|
||||||
|
|
||||||
// Verify win counts
|
|
||||||
expect(standingA?.wins).toBe(2); // Races 1 and 4
|
|
||||||
expect(standingB?.wins).toBe(2); // Races 2 and 5
|
|
||||||
expect(standingC?.wins).toBe(1); // Race 3
|
|
||||||
|
|
||||||
// Verify podium counts
|
|
||||||
expect(standingA?.podiums).toBe(5); // All races
|
|
||||||
expect(standingB?.podiums).toBe(5); // All races
|
|
||||||
expect(standingC?.podiums).toBe(5); // All races
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle standings with tied points correctly', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Tie Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Driver X', iracingId: '2001' }),
|
|
||||||
factory.createDriver({ name: 'Driver Y', iracingId: '2002' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const race1 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track A',
|
|
||||||
car: 'Car A',
|
|
||||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
const race2 = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track B',
|
|
||||||
car: 'Car A',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both drivers get same points: 25 + 18 = 43
|
|
||||||
await factory.createResult(race1.id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(race1.id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
|
||||||
|
|
||||||
await factory.createResult(race2.id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(race2.id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
expect(response.standings).toHaveLength(2);
|
|
||||||
|
|
||||||
// Both should have same points
|
|
||||||
expect(response.standings[0].points).toBe(43);
|
|
||||||
expect(response.standings[1].points).toBe(43);
|
|
||||||
|
|
||||||
// Positions should be 1 and 2 (tie-breaker logic may vary)
|
|
||||||
const positions = response.standings.map(s => s.position).sort();
|
|
||||||
expect(positions).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Data Consistency', () => {
|
|
||||||
it('should maintain data consistency across multiple API calls', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Consistency Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Consistent Driver', country: 'DE' });
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Nürburgring',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
|
|
||||||
// Make multiple calls
|
|
||||||
const response1 = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
const response2 = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
const response3 = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
// All responses should be identical
|
|
||||||
expect(response1).toEqual(response2);
|
|
||||||
expect(response2).toEqual(response3);
|
|
||||||
|
|
||||||
// Verify data integrity
|
|
||||||
expect(response1.standings).toHaveLength(1);
|
|
||||||
expect(response1.standings[0].driver.name).toBe('Consistent Driver');
|
|
||||||
expect(response1.standings[0].points).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with many drivers and races', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Large League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create 10 drivers
|
|
||||||
const drivers = await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
factory.createDriver({ name: `Driver ${i + 1}`, iracingId: `${3000 + i}` })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create 10 races
|
|
||||||
const races = await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: `Track ${i + 1}`,
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - (10 - i) * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create results for each race (random but consistent positions)
|
|
||||||
for (let raceIndex = 0; raceIndex < 10; raceIndex++) {
|
|
||||||
for (let driverIndex = 0; driverIndex < 10; driverIndex++) {
|
|
||||||
const position = ((driverIndex + raceIndex) % 10) + 1;
|
|
||||||
await factory.createResult(
|
|
||||||
races[raceIndex].id.toString(),
|
|
||||||
drivers[driverIndex].id.toString(),
|
|
||||||
{
|
|
||||||
position,
|
|
||||||
fastestLap: 85000 + (position * 100),
|
|
||||||
incidents: position % 3,
|
|
||||||
startPosition: position
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
// Should have all 10 drivers
|
|
||||||
expect(response.standings).toHaveLength(10);
|
|
||||||
|
|
||||||
// All drivers should have 10 races
|
|
||||||
for (const standing of response.standings) {
|
|
||||||
expect(standing.races).toBe(10);
|
|
||||||
expect(standing.driver).toBeDefined();
|
|
||||||
expect(standing.driver.id).toBeDefined();
|
|
||||||
expect(standing.driver.name).toBeDefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positions should be unique 1-10
|
|
||||||
const positions = response.standings.map(s => s.position).sort((a, b) => a - b);
|
|
||||||
expect(positions).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle missing fields gracefully', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Edge Case League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create driver without bio (should be optional)
|
|
||||||
const driver = await factory.createDriver({ name: 'Test Driver', country: 'US' });
|
|
||||||
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Test Track',
|
|
||||||
car: 'Test Car',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
|
||||||
|
|
||||||
expect(response.standings).toHaveLength(1);
|
|
||||||
expect(response.standings[0].driver.bio).toBeUndefined(); // Optional field
|
|
||||||
expect(response.standings[0].driver.name).toBe('Test Driver');
|
|
||||||
expect(response.standings[0].driver.country).toBe('US');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Test: League Stats Data Flow
|
|
||||||
*
|
|
||||||
* Tests the complete data flow from database to API response for league stats:
|
|
||||||
* 1. Database query returns correct data
|
|
||||||
* 2. Use case processes the data correctly
|
|
||||||
* 3. Presenter transforms data to DTOs
|
|
||||||
* 4. API returns correct response
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
||||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
|
||||||
|
|
||||||
describe('League Stats - Data Flow Integration', () => {
|
|
||||||
let harness: IntegrationTestHarness;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
harness = createTestHarness();
|
|
||||||
await harness.beforeAll();
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await harness.afterAll();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await harness.beforeEach();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('API to View Data Flow', () => {
|
|
||||||
it('should return correct stats DTO structure from API', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Stats Test League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create drivers
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Driver 1', country: 'US' }),
|
|
||||||
factory.createDriver({ name: 'Driver 2', country: 'UK' }),
|
|
||||||
factory.createDriver({ name: 'Driver 3', country: 'CA' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create races
|
|
||||||
const races = await Promise.all([
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track 1',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track 2',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Track 3',
|
|
||||||
car: 'Car 1',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create results
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 91000, incidents: 2, startPosition: 3 });
|
|
||||||
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 3 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 89500, incidents: 1, startPosition: 1 });
|
|
||||||
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 88500, incidents: 2, startPosition: 3 });
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 88000, incidents: 1, startPosition: 1 });
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[2].id.toString(), { position: 1, fastestLap: 87500, incidents: 0, startPosition: 2 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
// Verify: API response structure
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response).toHaveProperty('totalRaces');
|
|
||||||
expect(response).toHaveProperty('totalDrivers');
|
|
||||||
expect(response).toHaveProperty('totalResults');
|
|
||||||
expect(response).toHaveProperty('averageIncidentsPerRace');
|
|
||||||
expect(response).toHaveProperty('mostCommonTrack');
|
|
||||||
expect(response).toHaveProperty('mostCommonCar');
|
|
||||||
expect(response).toHaveProperty('topPerformers');
|
|
||||||
expect(Array.isArray(response.topPerformers)).toBe(true);
|
|
||||||
|
|
||||||
// Verify: Top performers structure
|
|
||||||
for (const performer of response.topPerformers) {
|
|
||||||
expect(performer).toHaveProperty('driverId');
|
|
||||||
expect(performer).toHaveProperty('driver');
|
|
||||||
expect(performer).toHaveProperty('points');
|
|
||||||
expect(performer).toHaveProperty('wins');
|
|
||||||
expect(performer).toHaveProperty('podiums');
|
|
||||||
expect(performer).toHaveProperty('races');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty stats for league with no data', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Empty Stats League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
expect(response.totalRaces).toBe(0);
|
|
||||||
expect(response.totalDrivers).toBe(0);
|
|
||||||
expect(response.totalResults).toBe(0);
|
|
||||||
expect(response.topPerformers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle stats with single race', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Single Race Stats League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Solo Driver', country: 'US' });
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Monza',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
expect(response.totalRaces).toBe(1);
|
|
||||||
expect(response.totalDrivers).toBe(1);
|
|
||||||
expect(response.totalResults).toBe(1);
|
|
||||||
expect(response.topPerformers).toHaveLength(1);
|
|
||||||
expect(response.topPerformers[0].driver.name).toBe('Solo Driver');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('End-to-End Data Flow', () => {
|
|
||||||
it('should correctly calculate stats from race results', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Calculation Stats League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Driver A', iracingId: '1001' }),
|
|
||||||
factory.createDriver({ name: 'Driver B', iracingId: '1002' }),
|
|
||||||
factory.createDriver({ name: 'Driver C', iracingId: '1003' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create 5 races with different tracks and cars
|
|
||||||
const races = await Promise.all([
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Road Atlanta',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Laguna Seca',
|
|
||||||
car: 'Formula Ford',
|
|
||||||
scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Nürburgring',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Road Atlanta',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create results with specific incidents
|
|
||||||
// Race 1: Laguna Seca, Formula Ford
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 95000, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 95500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 96000, incidents: 2, startPosition: 3 });
|
|
||||||
|
|
||||||
// Race 2: Road Atlanta, Formula Ford
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 94500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 94000, incidents: 0, startPosition: 3 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 95000, incidents: 1, startPosition: 1 });
|
|
||||||
|
|
||||||
// Race 3: Laguna Seca, Formula Ford
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 93500, incidents: 0, startPosition: 1 });
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 94000, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[2].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 94500, incidents: 2, startPosition: 3 });
|
|
||||||
|
|
||||||
// Race 4: Nürburgring, GT3
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 92500, incidents: 2, startPosition: 3 });
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 92000, incidents: 1, startPosition: 1 });
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[2].id.toString(), { position: 1, fastestLap: 91500, incidents: 0, startPosition: 2 });
|
|
||||||
|
|
||||||
// Race 5: Road Atlanta, GT3
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 91000, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 90500, incidents: 0, startPosition: 3 });
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 91500, incidents: 1, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
// Verify calculated stats
|
|
||||||
expect(response.totalRaces).toBe(5);
|
|
||||||
expect(response.totalDrivers).toBe(3);
|
|
||||||
expect(response.totalResults).toBe(15);
|
|
||||||
|
|
||||||
// Verify average incidents per race
|
|
||||||
// Total incidents: 0+1+2 + 1+0+1 + 0+1+2 + 2+1+0 + 1+0+1 = 15
|
|
||||||
// Average: 15 / 5 = 3
|
|
||||||
expect(response.averageIncidentsPerRace).toBe(3);
|
|
||||||
|
|
||||||
// Verify most common track (Laguna Seca appears 2 times, Road Atlanta 2 times, Nürburgring 1 time)
|
|
||||||
// Should return one of the most common tracks
|
|
||||||
expect(['Laguna Seca', 'Road Atlanta']).toContain(response.mostCommonTrack);
|
|
||||||
|
|
||||||
// Verify most common car (Formula Ford appears 3 times, GT3 appears 2 times)
|
|
||||||
expect(response.mostCommonCar).toBe('Formula Ford');
|
|
||||||
|
|
||||||
// Verify top performers
|
|
||||||
expect(response.topPerformers).toHaveLength(3);
|
|
||||||
|
|
||||||
// Find drivers in response
|
|
||||||
const performerA = response.topPerformers.find(p => p.driver.name === 'Driver A');
|
|
||||||
const performerB = response.topPerformers.find(p => p.driver.name === 'Driver B');
|
|
||||||
const performerC = response.topPerformers.find(p => p.driver.name === 'Driver C');
|
|
||||||
|
|
||||||
expect(performerA).toBeDefined();
|
|
||||||
expect(performerB).toBeDefined();
|
|
||||||
expect(performerC).toBeDefined();
|
|
||||||
|
|
||||||
// Verify race counts
|
|
||||||
expect(performerA?.races).toBe(5);
|
|
||||||
expect(performerB?.races).toBe(5);
|
|
||||||
expect(performerC?.races).toBe(5);
|
|
||||||
|
|
||||||
// Verify win counts
|
|
||||||
expect(performerA?.wins).toBe(2); // Races 1 and 3
|
|
||||||
expect(performerB?.wins).toBe(2); // Races 2 and 5
|
|
||||||
expect(performerC?.wins).toBe(1); // Race 4
|
|
||||||
|
|
||||||
// Verify podium counts
|
|
||||||
expect(performerA?.podiums).toBe(5); // All races
|
|
||||||
expect(performerB?.podiums).toBe(5); // All races
|
|
||||||
expect(performerC?.podiums).toBe(5); // All races
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle stats with varying race counts per driver', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Varying Races League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const drivers = await Promise.all([
|
|
||||||
factory.createDriver({ name: 'Full Timer', iracingId: '2001' }),
|
|
||||||
factory.createDriver({ name: 'Part Timer', iracingId: '2002' }),
|
|
||||||
factory.createDriver({ name: 'One Race', iracingId: '2003' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Create 5 races
|
|
||||||
const races = await Promise.all([
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 1', car: 'Car 1', scheduledAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 2', car: 'Car 1', scheduledAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 3', car: 'Car 1', scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 4', car: 'Car 1', scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 5', car: 'Car 1', scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Full Timer: all 5 races
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
await factory.createResult(races[i].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000 + i * 100, incidents: i % 2, startPosition: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Part Timer: 3 races (1, 2, 4)
|
|
||||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90600, incidents: 1, startPosition: 2 });
|
|
||||||
await factory.createResult(races[3].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90800, incidents: 1, startPosition: 2 });
|
|
||||||
|
|
||||||
// One Race: only race 5
|
|
||||||
await factory.createResult(races[4].id.toString(), drivers[2].id.toString(), { position: 2, fastestLap: 90900, incidents: 0, startPosition: 2 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
expect(response.totalRaces).toBe(5);
|
|
||||||
expect(response.totalDrivers).toBe(3);
|
|
||||||
expect(response.totalResults).toBe(9); // 5 + 3 + 1
|
|
||||||
|
|
||||||
// Verify top performers have correct race counts
|
|
||||||
const fullTimer = response.topPerformers.find(p => p.driver.name === 'Full Timer');
|
|
||||||
const partTimer = response.topPerformers.find(p => p.driver.name === 'Part Timer');
|
|
||||||
const oneRace = response.topPerformers.find(p => p.driver.name === 'One Race');
|
|
||||||
|
|
||||||
expect(fullTimer?.races).toBe(5);
|
|
||||||
expect(partTimer?.races).toBe(3);
|
|
||||||
expect(oneRace?.races).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Data Consistency', () => {
|
|
||||||
it('should maintain data consistency across multiple API calls', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Consistency Stats League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Consistent Driver', country: 'DE' });
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Nürburgring',
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
|
|
||||||
// Make multiple calls
|
|
||||||
const response1 = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
const response2 = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
const response3 = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
// All responses should be identical
|
|
||||||
expect(response1).toEqual(response2);
|
|
||||||
expect(response2).toEqual(response3);
|
|
||||||
|
|
||||||
// Verify data integrity
|
|
||||||
expect(response1.totalRaces).toBe(1);
|
|
||||||
expect(response1.totalDrivers).toBe(1);
|
|
||||||
expect(response1.totalResults).toBe(1);
|
|
||||||
expect(response1.topPerformers).toHaveLength(1);
|
|
||||||
expect(response1.topPerformers[0].driver.name).toBe('Consistent Driver');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with many races and drivers', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Large Stats League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
// Create 10 drivers
|
|
||||||
const drivers = await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
factory.createDriver({ name: `Driver ${i + 1}`, iracingId: `${3000 + i}` })
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create 10 races
|
|
||||||
const races = await Promise.all(
|
|
||||||
Array.from({ length: 10 }, (_, i) =>
|
|
||||||
factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: `Track ${i + 1}`,
|
|
||||||
car: 'GT3',
|
|
||||||
scheduledAt: new Date(Date.now() - (10 - i) * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create results for each race (all drivers participate)
|
|
||||||
for (let raceIndex = 0; raceIndex < 10; raceIndex++) {
|
|
||||||
for (let driverIndex = 0; driverIndex < 10; driverIndex++) {
|
|
||||||
const position = ((driverIndex + raceIndex) % 10) + 1;
|
|
||||||
await factory.createResult(
|
|
||||||
races[raceIndex].id.toString(),
|
|
||||||
drivers[driverIndex].id.toString(),
|
|
||||||
{
|
|
||||||
position,
|
|
||||||
fastestLap: 85000 + (position * 100),
|
|
||||||
incidents: position % 3,
|
|
||||||
startPosition: position
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
// Should have correct totals
|
|
||||||
expect(response.totalRaces).toBe(10);
|
|
||||||
expect(response.totalDrivers).toBe(10);
|
|
||||||
expect(response.totalResults).toBe(100); // 10 races * 10 drivers
|
|
||||||
|
|
||||||
// Should have 10 top performers (one per driver)
|
|
||||||
expect(response.topPerformers).toHaveLength(10);
|
|
||||||
|
|
||||||
// All top performers should have 10 races
|
|
||||||
for (const performer of response.topPerformers) {
|
|
||||||
expect(performer.races).toBe(10);
|
|
||||||
expect(performer.driver).toBeDefined();
|
|
||||||
expect(performer.driver.id).toBeDefined();
|
|
||||||
expect(performer.driver.name).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with no completed races', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'No Completed Races League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Waiting Driver', country: 'US' });
|
|
||||||
|
|
||||||
// Create only scheduled races (no completed races)
|
|
||||||
const race = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Future Track',
|
|
||||||
car: 'Future Car',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
// Should have 0 stats since no completed races
|
|
||||||
expect(response.totalRaces).toBe(0);
|
|
||||||
expect(response.totalDrivers).toBe(0);
|
|
||||||
expect(response.totalResults).toBe(0);
|
|
||||||
expect(response.topPerformers).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle edge case: league with mixed race statuses', async () => {
|
|
||||||
const factory = harness.getFactory();
|
|
||||||
const league = await factory.createLeague({ name: 'Mixed Status League' });
|
|
||||||
const season = await factory.createSeason(league.id.toString());
|
|
||||||
|
|
||||||
const driver = await factory.createDriver({ name: 'Mixed Driver', country: 'US' });
|
|
||||||
|
|
||||||
// Create races with different statuses
|
|
||||||
const completedRace = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Completed Track',
|
|
||||||
car: 'Completed Car',
|
|
||||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
const scheduledRace = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'Scheduled Track',
|
|
||||||
car: 'Scheduled Car',
|
|
||||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'scheduled'
|
|
||||||
});
|
|
||||||
|
|
||||||
const inProgressRace = await factory.createRace({
|
|
||||||
leagueId: league.id.toString(),
|
|
||||||
track: 'In Progress Track',
|
|
||||||
car: 'In Progress Car',
|
|
||||||
scheduledAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000),
|
|
||||||
status: 'in_progress'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add result only to completed race
|
|
||||||
await factory.createResult(completedRace.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
|
||||||
|
|
||||||
const api = harness.getApi();
|
|
||||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
|
||||||
|
|
||||||
// Should only count completed races
|
|
||||||
expect(response.totalRaces).toBe(1);
|
|
||||||
expect(response.totalDrivers).toBe(1);
|
|
||||||
expect(response.totalResults).toBe(1);
|
|
||||||
expect(response.topPerformers).toHaveLength(1);
|
|
||||||
expect(response.topPerformers[0].driver.name).toBe('Mixed Driver');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Test: Race Results Import API
|
|
||||||
*
|
|
||||||
* Tests the race results import endpoint with various scenarios.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
||||||
import { ApiClient } from '../harness/api-client';
|
|
||||||
import { DockerManager } from '../harness/docker-manager';
|
|
||||||
|
|
||||||
describe('Race Results Import - API Integration', () => {
|
|
||||||
let api: ApiClient;
|
|
||||||
let docker: DockerManager;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
docker = DockerManager.getInstance();
|
|
||||||
await docker.start();
|
|
||||||
|
|
||||||
api = new ApiClient({ baseUrl: 'http://localhost:3101', timeout: 60000 });
|
|
||||||
await api.waitForReady();
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
docker.stop();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
it('should return 404 for non-existent race', async () => {
|
|
||||||
const nonExistentRaceId = 'non-existent-race-123';
|
|
||||||
const results = [
|
|
||||||
{
|
|
||||||
driverId: 'driver-1',
|
|
||||||
position: 1,
|
|
||||||
fastestLap: 100,
|
|
||||||
incidents: 0,
|
|
||||||
startPosition: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/races/${nonExistentRaceId}/import-results`, {
|
|
||||||
resultsFileContent: JSON.stringify(results),
|
|
||||||
raceId: nonExistentRaceId,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle invalid JSON gracefully', async () => {
|
|
||||||
const raceId = 'test-race-1';
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/races/${raceId}/import-results`, {
|
|
||||||
resultsFileContent: 'invalid json {',
|
|
||||||
raceId,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty results array', async () => {
|
|
||||||
const raceId = 'test-race-1';
|
|
||||||
const emptyResults: unknown[] = [];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/races/${raceId}/import-results`, {
|
|
||||||
resultsFileContent: JSON.stringify(emptyResults),
|
|
||||||
raceId,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle missing required fields', async () => {
|
|
||||||
const raceId = 'test-race-1';
|
|
||||||
const invalidResults = [
|
|
||||||
{
|
|
||||||
// Missing required fields
|
|
||||||
driverId: 'driver-1',
|
|
||||||
position: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
api.post(`/races/${raceId}/import-results`, {
|
|
||||||
resultsFileContent: JSON.stringify(invalidResults),
|
|
||||||
raceId,
|
|
||||||
})
|
|
||||||
).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should verify API health endpoint works', async () => {
|
|
||||||
const isHealthy = await api.health();
|
|
||||||
expect(isHealthy).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user