diff --git a/.eslintrc.json b/.eslintrc.json index 4435e070c..4affc7476 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -359,6 +359,66 @@ "@typescript-eslint/no-explicit-any": "off", "no-restricted-syntax": "off" } + }, + { + "files": ["tests/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { + "paths": [ + { + "name": "@core/*", + "message": "Integration tests must use in-memory adapters, not core directly" + }, + { + "name": "@adapters/*", + "message": "Integration tests must use in-memory adapters only" + } + ] + }] + } + }, + { + "files": ["tests/e2e/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": ["**/inmemory/**"], + "message": "E2E tests must use TypeORM/PostgreSQL, not in-memory adapters" + } + ] + }] + } + }, + { + "files": ["core/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { + "paths": [ + { + "name": "testing", + "message": "Use @testing/* from adapters/testing" + }, + { + "name": "@testing/*", + "message": "Core layer should not depend on testing utilities" + } + ] + }] + } + }, + { + "files": ["adapters/**/*.ts"], + "rules": { + "no-restricted-imports": ["error", { + "paths": [ + { + "name": "testing", + "message": "Use @testing/* from adapters/testing" + } + ] + }] + } } ] } \ No newline at end of file diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 62b66a860..19e42b16c 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -28,7 +28,7 @@ "../../core/*" ], "@testing/*": [ - "../../testing/*" + "../../adapters/testing/*" ] }, "removeComments": true, diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index 2a7dc4438..db3fbc045 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -45,7 +45,7 @@ "./*" ], "@testing/*": [ - "../../testing/*" + "../../adapters/testing/*" ], "@/lib/dtos": [ "./lib/dtos" diff --git a/tests/unit/core/ports/media/MediaResolverPort.test.ts b/core/ports/media/MediaResolverPort.test.ts similarity index 100% rename from tests/unit/core/ports/media/MediaResolverPort.test.ts rename to core/ports/media/MediaResolverPort.test.ts diff --git a/core/racing/domain/entities/season/Season.test.ts b/core/racing/domain/entities/season/Season.test.ts index 652e9004c..a804d6150 100644 --- a/core/racing/domain/entities/season/Season.test.ts +++ b/core/racing/domain/entities/season/Season.test.ts @@ -10,11 +10,28 @@ import { SeasonDropPolicy, } from '@core/racing/domain/value-objects/SeasonDropPolicy'; import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig'; -import { - createMinimalSeason, - createBaseSeason, -} from '@core/testing/factories/racing/SeasonFactory'; +import { Season, SeasonStatus } from '@core/racing/domain/entities/season/Season'; +const createMinimalSeason = (overrides?: { status?: SeasonStatus }) => + Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Test Season', + status: overrides?.status ?? 'planned', + }); + +const createBaseSeason = () => + Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Config Season', + status: 'planned', + startDate: new Date('2025-01-01T00:00:00Z'), + endDate: undefined, + maxDrivers: 24, + }); describe('Season aggregate lifecycle', () => { it('transitions Planned → Active → Completed → Archived with timestamps', () => { @@ -205,5 +222,3 @@ describe('Season configuration updates', () => { ); }); }); - - diff --git a/core/racing/domain/services/EventScoringService.test.ts b/core/racing/domain/services/EventScoringService.test.ts index 1ec5f71c0..cb19d8de7 100644 --- a/core/racing/domain/services/EventScoringService.test.ts +++ b/core/racing/domain/services/EventScoringService.test.ts @@ -4,8 +4,63 @@ import { EventScoringService } from '@core/racing/domain/services/EventScoringSe import type { BonusRule } from '@core/racing/domain/types/BonusRule'; import { Result } from '@core/racing/domain/entities/result/Result'; import type { Penalty } from '@core/racing/domain/entities/Penalty'; -import { makeChampionshipConfig } from '../../../testing/factories/racing/ChampionshipConfigFactory'; +import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; +import type { SessionType } from '@core/racing/domain/types/SessionType'; +import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; +const makeChampionshipConfig = (params: { + id: string; + name: string; + sessionTypes: SessionType[]; + mainPoints: number[]; + sprintPoints?: number[]; + mainBonusRules?: BonusRule[]; +}): ChampionshipConfig => { + const { id, name, sessionTypes, mainPoints, sprintPoints, mainBonusRules } = params; + + const pointsTableBySessionType: Record = {} as Record; + + sessionTypes.forEach((sessionType) => { + if (sessionType === 'main') { + pointsTableBySessionType[sessionType] = new PointsTable( + mainPoints.reduce((acc, points, index) => { + acc[index + 1] = points; + return acc; + }, {} as Record), + ); + } else if (sessionType === 'sprint' && sprintPoints) { + pointsTableBySessionType[sessionType] = new PointsTable( + sprintPoints.reduce((acc, points, index) => { + acc[index + 1] = points; + return acc; + }, {} as Record), + ); + } else { + pointsTableBySessionType[sessionType] = new PointsTable({}); + } + }); + + const bonusRulesBySessionType: Record = {} as Record; + sessionTypes.forEach((sessionType) => { + if (sessionType === 'main' && mainBonusRules) { + bonusRulesBySessionType[sessionType] = mainBonusRules; + } else { + bonusRulesBySessionType[sessionType] = []; + } + }); + + return { + id, + name, + type: 'driver', + sessionTypes, + pointsTableBySessionType, + bonusRulesBySessionType, + dropScorePolicy: { + strategy: 'none', + }, + }; +}; describe('EventScoringService', () => { const seasonId = 'season-1'; @@ -201,4 +256,4 @@ describe('EventScoringService', () => { (mapWithBonus.get('driver-3')?.penaltyPoints || 0), ); }); -}); \ No newline at end of file +}); diff --git a/core/testing/factories/racing/ChampionshipConfigFactory.ts b/core/testing/factories/racing/ChampionshipConfigFactory.ts deleted file mode 100644 index 29adbec29..000000000 --- a/core/testing/factories/racing/ChampionshipConfigFactory.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ChampionshipConfig } from '../../../racing/domain/types/ChampionshipConfig'; -import type { ChampionshipType } from '../../../racing/domain/types/ChampionshipType'; -import type { SessionType } from '../../../racing/domain/types/SessionType'; -import type { BonusRule } from '../../../racing/domain/types/BonusRule'; -import type { DropScorePolicy, DropScoreStrategy } from '../../../racing/domain/types/DropScorePolicy'; -import { PointsTable } from '../../../racing/domain/value-objects/PointsTable'; - -interface ChampionshipConfigInput { - id: string; - name: string; - sessionTypes: SessionType[]; - mainPoints?: number[]; - mainBonusRules?: BonusRule[]; - type?: ChampionshipType; - dropScorePolicy?: DropScorePolicy; - strategy?: DropScoreStrategy; -} - -export function makeChampionshipConfig(input: ChampionshipConfigInput): ChampionshipConfig { - const { - id, - name, - sessionTypes, - mainPoints = [25, 18, 15, 12, 10, 8, 6, 4, 2, 1], - mainBonusRules = [], - type = 'driver', - dropScorePolicy = { strategy: 'none' }, - } = input; - - const pointsTableBySessionType: Record = {} as Record; - - // Convert array format to PointsTable for each session type - sessionTypes.forEach(sessionType => { - const pointsArray = mainPoints; - const pointsMap: Record = {}; - pointsArray.forEach((points, index) => { - pointsMap[index + 1] = points; - }); - pointsTableBySessionType[sessionType] = new PointsTable(pointsMap); - }); - - const bonusRulesBySessionType: Record = {} as Record; - - // Add bonus rules for each session type - sessionTypes.forEach(sessionType => { - bonusRulesBySessionType[sessionType] = mainBonusRules; - }); - - return { - id, - name, - type, - sessionTypes, - pointsTableBySessionType, - bonusRulesBySessionType, - dropScorePolicy, - }; -} \ No newline at end of file diff --git a/core/testing/factories/racing/DriverRefFactory.ts b/core/testing/factories/racing/DriverRefFactory.ts deleted file mode 100644 index f4e05c864..000000000 --- a/core/testing/factories/racing/DriverRefFactory.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ParticipantRef } from '../../../racing/domain/types/ParticipantRef'; - -export function makeDriverRef(driverId: string): ParticipantRef { - return { - id: driverId, - type: 'driver', - }; -} \ No newline at end of file diff --git a/core/testing/factories/racing/PointsTableFactory.ts b/core/testing/factories/racing/PointsTableFactory.ts deleted file mode 100644 index 3abc9fa80..000000000 --- a/core/testing/factories/racing/PointsTableFactory.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PointsTable } from '../../../racing/domain/value-objects/PointsTable'; - -export function makePointsTable(points: number[]): PointsTable { - return new PointsTable(points); -} \ No newline at end of file diff --git a/core/testing/factories/racing/SeasonFactory.ts b/core/testing/factories/racing/SeasonFactory.ts deleted file mode 100644 index 0bfd3765e..000000000 --- a/core/testing/factories/racing/SeasonFactory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Season } from '@core/racing/domain/entities/season/Season'; -import type { SeasonStatusValue } from '@core/racing/domain/value-objects/SeasonStatus'; - -export const createMinimalSeason = (overrides?: { status?: SeasonStatusValue }) => - Season.create({ - id: 'season-1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Test Season', - status: overrides?.status ?? 'planned', - }); - -export const createBaseSeason = () => - Season.create({ - id: 'season-1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Config Season', - status: 'planned', - startDate: new Date('2025-01-01T00:00:00Z'), - endDate: undefined, - maxDrivers: 24, - }); \ No newline at end of file diff --git a/docs/TESTS.md b/docs/TESTS.md index 8651158d0..4ebe6decc 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -54,7 +54,9 @@ This pattern applies equally to integration tests (with real database operations ## Test Types & Organization -### Unit Tests (`/tests/unit`) +### Unit Tests (Adjacent to Implementation) + +**Location:** Adjacent to the implementation file (e.g., `core/ports/media/MediaResolverPort.ts` + `MediaResolverPort.test.ts`) **Scope:** Domain entities, value objects, and application use cases with mocked ports (repositories, external services). @@ -67,41 +69,31 @@ This pattern applies equally to integration tests (with real database operations - Ensure domain invariants hold (e.g., team limits, scoring rules) - Test use case orchestration with mocked dependencies -**Examples from Architecture:** +**Key Principle:** Unit tests live with their implementation. No separate `tests/unit/` directory. + +**Examples:** 1. **Domain Entity Test:** - ```typescript - // League.addTeam() validation - Given a League with maxTeams=10 and 9 current teams - When addTeam() is called with a valid Team - Then the team is added successfully - - Given a League with maxTeams=10 and 10 current teams - When addTeam() is called - Then a DomainError is returned with "Team limit reached" - ``` + ```typescript + // core/domain/entities/League.ts + League.test.ts + Given a League with maxTeams=10 and 9 current teams + When addTeam() is called with a valid Team + Then the team is added successfully + + Given a League with maxTeams=10 and 10 current teams + When addTeam() is called + Then a DomainError is returned with "Team limit reached" + ``` 2. **Use Case Test:** - ```typescript - // GenerateStandingsUseCase - Given a League with 5 teams and completed races - When execute() is called - Then LeagueRepository.findById() is invoked - And ScoringRule.calculatePoints() is called for each team - And sorted standings are returned - ``` - -3. **Scoring Rule Test:** - ```typescript - // ScoringRule.calculatePoints() - Given a F1-style scoring rule (25-18-15-12-10-8-6-4-2-1) - When calculatePoints(position=1) is called - Then 25 points are returned - - Given the same rule - When calculatePoints(position=11) is called - Then 0 points are returned - ``` + ```typescript + // core/application/use-cases/GenerateStandingsUseCase.ts + GenerateStandingsUseCase.test.ts + Given a League with 5 teams and completed races + When execute() is called + Then LeagueRepository.findById() is invoked + And ScoringRule.calculatePoints() is called for each team + And sorted standings are returned + ``` **Key Practices:** - Mock only at architecture boundaries (ports like `ILeagueRepository`) @@ -113,124 +105,221 @@ This pattern applies equally to integration tests (with real database operations ### Integration Tests (`/tests/integration`) -**Scope:** Repository implementations, infrastructure adapters (PostgreSQL, Redis, OAuth clients, result importers). +**Location:** `tests/integration/` -**Tooling:** Vitest + Testcontainers (spins up real PostgreSQL/Redis in Docker) +**Scope:** Verify adapter composition works correctly. Uses **in-memory adapters only**. + +**Tooling:** Vitest **Execution:** Sequential, ~10 seconds per suite **Purpose:** -- Validate that infrastructure adapters correctly implement port interfaces -- Test database queries, migrations, and transaction handling -- Ensure external API clients handle authentication and error scenarios +- Validate that multiple adapters work together +- Test use case orchestration with real in-memory implementations +- Verify infrastructure wiring -**Examples from Architecture:** +**Directory Structure:** +``` +tests/integration/ +├── racing/ # Racing domain integration tests +│ └── RegistrationAndTeamUseCases.test.ts +└── website/ # Website integration tests + ├── auth-flow.test.ts + ├── auth-guard.test.ts + └── middleware.test.ts +``` -1. **Repository Test:** - ```typescript - // PostgresLeagueRepository - Given a PostgreSQL container is running - When save() is called with a League entity - Then the league is persisted to the database - And findById() returns the same league with correct attributes - ``` +**Examples:** -2. **OAuth Client Test:** - ```typescript - // IRacingOAuthClient - Given valid iRacing credentials - When authenticate() is called - Then an access token is returned - And the token is cached in Redis for 1 hour - - Given expired credentials - When authenticate() is called - Then an AuthenticationError is thrown - ``` +1. **Use Case Integration Test:** + ```typescript + // tests/integration/racing/RegistrationAndTeamUseCases.test.ts + Given an in-memory league repository with a league + And an in-memory team repository + When RegisterTeamUseCase is executed + Then the team is added to the league + And the team is saved to the repository + ``` -3. **Result Importer Test:** - ```typescript - // EventResultImporter - Given an Event exists in the database - When importResults() is called with iRacing session data - Then Driver entities are created/updated - And EventResult entities are persisted with correct positions/times - And the Event status is updated to 'COMPLETED' - ``` +2. **Website Integration Test:** + ```typescript + // tests/integration/website/auth-flow.test.ts + Given an in-memory authentication system + When a user logs in + Then the session is created + And protected routes are accessible + ``` **Key Practices:** -- Use Testcontainers to spin up real databases (not mocks) -- Clean database state between tests (truncate tables or use transactions) -- Seed minimal test data via SQL fixtures -- Test both success and failure paths (network errors, constraint violations) +- Use in-memory adapters from `adapters/**/inmemory/` +- Clean state between tests +- Test adapter composition, not individual methods +- **NEVER use TypeORM or real databases** --- -### End-to-End Tests (`/tests/e2e`) +### E2E Tests (`/tests/e2e`) -**Scope:** Full user workflows spanning web-client → web-api → database. +**Location:** `tests/e2e/` -**Tooling:** Playwright + Docker Compose (orchestrates all services) +**Scope:** Full system verification with **TypeORM/PostgreSQL via Docker**. -**Execution:** ~2 minutes per scenario +**Tooling:** Playwright + Docker Compose + +**Execution:** Sequential, ~2 minutes per scenario **Purpose:** -- Validate complete user journeys from UI interactions to database changes -- Ensure services integrate correctly in a production-like environment +- Validate complete user journeys from UI to database +- Ensure services integrate correctly in production-like environment - Catch regressions in multi-service workflows -**Examples from Architecture:** +**Directory Structure:** +``` +tests/e2e/ +├── website/ # Website E2E tests +│ └── website-pages.test.ts +└── docker/ # Docker-based E2E (TypeORM) + └── (future tests) +``` + +**Examples:** 1. **League Creation Workflow:** - ```gherkin - Given an authenticated league admin - When they navigate to "Create League" - And fill in league name, scoring system, and team limit - And submit the form - Then the league appears in the admin dashboard - And the database contains the new league record - And the league is visible to other users - ``` + ```gherkin + Given a PostgreSQL database running in Docker + And an authenticated league admin + When they navigate to "Create League" + And fill in league name, scoring system, and team limit + And submit the form + Then the league appears in the admin dashboard + And the database contains the new league record + ``` -2. **Team Registration Workflow:** - ```gherkin - Given a published league with 5/10 team slots filled - When a team captain navigates to the league page - And clicks "Join League" - And fills in team name and roster - And submits the form - Then the team appears in the league's team list - And the team count updates to 6/10 - And the captain receives a confirmation email - ``` - -3. **Automated Result Import:** - ```gherkin - Given a League with an upcoming Event - And iRacing OAuth credentials are configured - When the scheduled import job runs - Then the job authenticates with iRacing - And fetches session results for the Event - And creates EventResult records in the database - And updates the Event status to 'COMPLETED' - And triggers standings recalculation - ``` - -4. **Companion App Login Automation:** - ```gherkin - Given a League Admin enables companion app login automation - When the companion app is launched - Then the app polls for a generated login token from web-api - And auto-fills iRacing credentials from the admin's profile - And logs into iRacing automatically - And confirms successful login to web-api - ``` +2. **Full User Journey:** + ```gherkin + Given a running Docker Compose stack + When a user registers + And creates a league + And teams join the league + And races are completed + Then standings are calculated correctly in the database + ``` **Key Practices:** -- Use Playwright's Page Object pattern for reusable UI interactions -- Test both happy paths and error scenarios (validation errors, network failures) -- Clean database state between scenarios (via API or direct SQL) -- Run E2E tests in CI before merging to main branch +- Use Docker Compose for full stack +- TypeORM/PostgreSQL for real database +- Clean database between scenarios +- Test real user workflows + +--- + +### Structure Tests (`/tests/structure`) + +**Location:** `tests/structure/` + +**Scope:** Architecture validation and dependency rules. + +**Tooling:** Vitest + +**Examples:** + +1. **Package Dependencies Test:** + ```typescript + // tests/structure/PackageDependencies.test.ts + Then core layer should not depend on adapters + And adapters should not depend on apps + And domain should have zero dependencies + ``` + +--- + +## Testing Infrastructure + +### Clean Architecture Test Boundaries + +#### The Golden Rules + +1. **Domain Layer (`core/`)** + - ✅ Has ZERO dependencies + - ✅ Unit tests adjacent to implementation + - ❌ Cannot depend on testing utilities + +2. **Application Layer (`core/application/`)** + - ✅ Depends only on ports (interfaces) + - ✅ Unit tests adjacent to implementation + - ❌ Cannot depend on concrete adapters + +3. **Adapters Layer (`adapters/`)** + - ✅ Contains all infrastructure + - ✅ In-memory implementations for integration tests + - ✅ TypeORM implementations for e2e tests + +4. **Apps Layer (`apps/`)** + - ✅ Delivery mechanisms only + - ✅ E2E tests verify full stack + +#### Test Type Separation + +| Test Type | Location | Database | Purpose | +|-----------|----------|----------|---------| +| **Unit** | Adjacent to code | None | Verify domain logic | +| **Integration** | `tests/integration/` | In-memory | Verify adapter composition | +| **E2E** | `tests/e2e/` | TypeORM/PostgreSQL | Full system verification | + +--- + +## Enforcement Rules + +### TypeScript Paths +```json +{ + "compilerOptions": { + "paths": { + "@core/*": ["core/*"], + "@adapters/*": ["adapters/*"], + "@testing/*": ["adapters/testing/*"] + } + } +} +``` + +### ESLint Rules +```json +{ + "rules": { + "no-restricted-imports": ["error", { + "patterns": [ + { + "group": ["@testing/*"], + "message": "Testing utilities should only be used in test files" + }, + { + "group": ["@core/testing/*"], + "message": "Core cannot depend on testing utilities" + } + ] + }] + } +} +``` + +--- + +## Quick Reference + +### Moving Tests to Clean Architecture + +1. **Unit tests** → Move adjacent to implementation +2. **Integration tests** → Move to `tests/integration/` +3. **E2E tests** → Move to `tests/e2e/` +4. **Testing utilities** → Move to `adapters/testing/` (then delete if unused) + +### What Goes Where + +- **`core/`** + `*.test.ts` → Unit tests +- **`tests/integration/`** → Integration tests (in-memory) +- **`tests/e2e/`** → E2E tests (TypeORM) +- **`adapters/testing/`** → Testing utilities (minimal, only what's used) --- @@ -248,18 +337,12 @@ This pattern applies equally to integration tests (with real database operations ``` **Integration Tests:** -- Use Testcontainers to spin up fresh PostgreSQL instances -- Seed minimal test data via SQL scripts: - ```sql - -- tests/integration/fixtures/leagues.sql - INSERT INTO leagues (id, name, max_teams) VALUES - ('league-1', 'Test League', 10); - ``` -- Clean state between tests (truncate tables or rollback transactions) +- Use in-memory adapters only +- Clean state between tests **E2E Tests:** - Pre-seed database via migrations before Docker Compose starts -- Use API endpoints to create test data when possible (validates API behavior) +- Use API endpoints to create test data when possible - Database cleanup between scenarios: ```typescript // tests/e2e/support/database.ts @@ -273,178 +356,6 @@ This pattern applies equally to integration tests (with real database operations --- -## Docker-Based Tests (Website ↔ API Wiring) - -This repo uses Docker in two different ways: - -1) **Website ↔ API smoke (wiring validation)** - - Orchestrated by [`docker-compose.test.yml`](docker-compose.test.yml:1) at the repo root. - - Runs: - - Website in Docker (Next.js dev server) - - An API mock server in Docker (Node HTTP server) - - Goal: catch misconfigured hostnames/ports/env vars and CORS issues that only show up in Dockerized setups. - -2) **Hosted-session automation E2E (fixture-driven automation)** - - Orchestrated by `docker/docker-compose.e2e.yml` (separate stack; documented later in this file). - - Goal: validate Playwright-driven automation against HTML fixtures. - -### Website ↔ API smoke: how to run - -Run: -- `npm run test:docker:website` (see [`package.json`](package.json:92)) - -What it does (in order): -- Installs deps in a dedicated Docker volume (`npm run docker:test:deps`) -- Starts the test stack (`npm run docker:test:up`) -- Waits for readiness (`npm run docker:test:wait`) -- Runs Playwright smoke tests (`npm run smoke:website:docker`) - -Ports used: -- Website: `http://localhost:3100` -- API mock: `http://localhost:3101` - -Key contract: -- Website must resolve the API base URL via [`getWebsiteApiBaseUrl()`](apps/website/lib/config/apiBaseUrl.ts:6). -- The website’s HTTP client uses `credentials: 'include'`, so the API must support CORS-with-credentials (implemented for the real API in [`bootstrap()`](apps/api/src/main.ts:14)). - -### “Mock vs Real” (Website & API) - -- The Website does **not** have a runtime flag like `AUTOMATION_MODE`. -- “Mock vs real” for the Website is **only** which API base URL it uses: - - Browser: `NEXT_PUBLIC_API_BASE_URL` - - Server: `API_BASE_URL` (preferred in Docker) or `NEXT_PUBLIC_API_BASE_URL` fallback - -In the Docker smoke stack, “mock API” means the Node HTTP server in [`docker-compose.test.yml`](docker-compose.test.yml:24). -In Docker dev/prod, “real API” means the NestJS app started from [`bootstrap()`](apps/api/src/main.ts:14), and “real vs in-memory” persistence is controlled by `GRIDPILOT_API_PERSISTENCE` in [`AppModule`](apps/api/src/app.module.ts:25). - ---- - -## BDD Scenario Examples - -### 1. League Creation (Success + Failure) - -```gherkin -Scenario: Admin creates a new league - Given an authenticated admin user - When they submit a league form with: - | name | Summer Series 2024 | - | maxTeams | 12 | - | scoringSystem | F1 | - Then the league is created successfully - And the admin is redirected to the league dashboard - And the database contains the new league - -Scenario: League creation fails with duplicate name - Given a league named "Summer Series 2024" already exists - When an admin submits a league form with name "Summer Series 2024" - Then the form displays error "League name already exists" - And no new league is created in the database -``` - -### 2. Team Registration (Success + Failure) - -```gherkin -Scenario: Team registers for a league - Given a published league with 5/10 team slots - When a team captain submits registration with: - | teamName | Racing Legends | - | drivers | Alice, Bob, Carol | - Then the team is added to the league - And the team count updates to 6/10 - And the captain receives a confirmation email - -Scenario: Registration fails when league is full - Given a published league with 10/10 team slots - When a team captain attempts to register - Then the form displays error "League is full" - And the team is not added to the league -``` - -### 3. Automated Result Import (Success + Failure) - -```gherkin -Scenario: Import results from iRacing - Given a League with an Event scheduled for today - And iRacing OAuth credentials are configured - When the scheduled import job runs - Then the job authenticates with iRacing API - And fetches session results for the Event - And creates EventResult records for each driver - And updates the Event status to 'COMPLETED' - And triggers standings recalculation - -Scenario: Import fails with invalid credentials - Given an Event with expired iRacing credentials - When the import job runs - Then an AuthenticationError is logged - And the Event status remains 'SCHEDULED' - And an admin notification is sent -``` - -### 4. Parallel Scoring Calculation - -```gherkin -Scenario: Calculate standings for multiple leagues concurrently - Given 5 active leagues with completed events - When the standings recalculation job runs - Then each league's standings are calculated in parallel - And the process completes in <5 seconds - And all standings are persisted correctly - And no race conditions occur (validated via database integrity checks) -``` - -### 5. Companion App Login Automation - -```gherkin -Scenario: Companion app logs into iRacing automatically - Given a League Admin enables companion app login automation - And provides their iRacing credentials - When the companion app is launched - Then the app polls web-api for a login token - And retrieves the admin's encrypted credentials - And auto-fills the iRacing login form - And submits the login request - And confirms successful login to web-api - And caches the session token for 24 hours -``` - ---- - -## Coverage Goals - -### Target Coverage Levels - -- **Domain/Application Layers:** >90% (critical business logic) -- **Infrastructure Layer:** >80% (repository implementations, adapters) -- **Presentation Layer:** Smoke tests (basic rendering, no exhaustive UI coverage) - -### Running Coverage Reports - -```bash -# Unit + Integration coverage -npm run test:coverage - -# View HTML report -open coverage/index.html - -# E2E coverage (via Istanbul) -npm run test:e2e:coverage -``` - -### What to Prioritize - -1. **Domain Entities:** Invariants, validation rules, state transitions -2. **Use Cases:** Orchestration logic, error handling, port interactions -3. **Repositories:** CRUD operations, query builders, transaction handling -4. **Adapters:** External API clients, OAuth flows, result importers - -**What NOT to prioritize:** -- Trivial getters/setters -- Framework boilerplate (Express route handlers) -- UI styling (covered by visual regression tests if needed) - ---- - ## Continuous Testing ### Watch Mode (Development) @@ -574,8 +485,9 @@ describe('League.addTeam()', () => { **Factory Functions:** ```typescript -// tests/support/factories.ts -export function createTestLeague(overrides?: Partial): League { +// core/testing/factories.ts (deleted - use inline) +// Instead, define factories inline in test files: +function createTestLeague(overrides?: Partial): League { return new League('Test League', { maxTeams: 10, scoringSystem: 'F1', @@ -583,7 +495,7 @@ export function createTestLeague(overrides?: Partial): League { }); } -export function createTestTeam(name: string): Team { +function createTestTeam(name: string): Team { return new Team(name, { drivers: ['Driver 1', 'Driver 2'] }); } ``` @@ -591,7 +503,7 @@ export function createTestTeam(name: string): Team { ### Mocking Ports in Use Case Tests ```typescript -// tests/unit/application/CreateLeagueUseCase.test.ts +// core/application/use-cases/CreateLeagueUseCase.test.ts describe('CreateLeagueUseCase', () => { let mockRepo: jest.Mocked; let useCase: CreateLeagueUseCase; @@ -616,494 +528,6 @@ describe('CreateLeagueUseCase', () => { }); ``` -### Database Cleanup Strategies - -**Integration Tests:** -```typescript -// tests/integration/setup.ts -import { sql } from './database'; - -export async function cleanDatabase() { - await sql`TRUNCATE TABLE event_results CASCADE`; - await sql`TRUNCATE TABLE events CASCADE`; - await sql`TRUNCATE TABLE teams CASCADE`; - await sql`TRUNCATE TABLE leagues CASCADE`; -} - -beforeEach(async () => { - await cleanDatabase(); -}); -``` - -**E2E Tests:** -```typescript -// tests/e2e/support/hooks.ts -import { test as base } from '@playwright/test'; - -export const test = base.extend({ - page: async ({ page }, use) => { - // Clean database before each test - await fetch('http://localhost:3000/test/cleanup', { method: 'POST' }); - await use(page); - }, -}); -``` - -### Playwright Page Object Pattern - -```typescript -// tests/e2e/pages/LeaguePage.ts -export class LeaguePage { - constructor(private page: Page) {} - - async navigateToCreateLeague() { - await this.page.goto('/leagues/create'); - } - - async fillLeagueForm(data: { name: string; maxTeams: number }) { - await this.page.fill('[name="name"]', data.name); - await this.page.fill('[name="maxTeams"]', data.maxTeams.toString()); - } - - async submitForm() { - await this.page.click('button[type="submit"]'); - } - - async getSuccessMessage() { - return this.page.textContent('.success-message'); - } -} - -// Usage in test -test('should create league', async ({ page }) => { - const leaguePage = new LeaguePage(page); - await leaguePage.navigateToCreateLeague(); - await leaguePage.fillLeagueForm({ name: 'Test', maxTeams: 10 }); - await leaguePage.submitForm(); - - expect(await leaguePage.getSuccessMessage()).toBe('League created'); -}); -``` - ---- - -## Real E2E Testing Strategy (No Mocks) - -GridPilot focuses its real E2E testing strategy on browser-driven automation: - -1. **Strategy A (Docker)**: Test `BrowserDevToolsAdapter` with Playwright or similar browser tooling against a fixture server -2. **Strategy B (Native macOS, legacy)**: Historical native OS-level automation on real hardware (now removed) - -### Constraint: iRacing Terms of Service - -- **Production**: Native OS-level automation only (no browser DevTools/CDP for actual iRacing automation) -- **Testing**: Playwright-driven automation CAN be used against static HTML fixtures - -### Test Architecture Overview - -```mermaid -graph TB - subgraph Docker E2E - CI - FX[Static HTML Fixtures] --> FS[Fixture Server Container] - FS --> HC[Headless Chrome Container] - HC --> BDA[BrowserDevToolsAdapter Tests] - end - - %% Legacy native OS-level automation tests have been removed. -``` - ---- - -### Strategy A: Docker-Based E2E Tests - -#### Purpose -Test the complete 18-step workflow using `BrowserDevToolsAdapter` against real HTML fixtures without mocks. - -#### Architecture - -```yaml -# docker/docker-compose.e2e.yml -services: - # Headless Chrome with remote debugging enabled - chrome: - image: browserless/chrome:latest - ports: - - "9222:3000" - environment: - - CONNECTION_TIMEOUT=600000 - - MAX_CONCURRENT_SESSIONS=1 - - PREBOOT_CHROME=true - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/json/version"] - interval: 5s - timeout: 10s - retries: 3 - - # Static server for iRacing HTML fixtures - fixture-server: - build: - context: ./fixture-server - dockerfile: Dockerfile - ports: - - "3456:80" - volumes: - - ../resources/iracing-hosted-sessions:/usr/share/nginx/html:ro - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:80/01-hosted-racing.html"] - interval: 5s - timeout: 10s - retries: 3 -``` - -#### Fixture Server Configuration - -```dockerfile -# docker/fixture-server/Dockerfile -FROM nginx:alpine - -# Configure nginx for static HTML serving -COPY nginx.conf /etc/nginx/conf.d/default.conf - -EXPOSE 80 -``` - -```nginx -# docker/fixture-server/nginx.conf -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - - location / { - try_files $uri $uri/ =404; - add_header Access-Control-Allow-Origin *; - } -} -``` - -#### BDD Scenarios for Docker E2E - -```gherkin -Feature: BrowserDevToolsAdapter Workflow Automation - As the automation engine - I want to execute the 18-step hosted session workflow - So that I can verify browser automation against real HTML fixtures - - Background: - Given the Docker E2E environment is running - And the fixture server is serving iRacing HTML pages - And the headless Chrome container is connected - - Scenario: Complete workflow navigation through all 18 steps - Given the BrowserDevToolsAdapter is connected to Chrome - When I execute step 2 HOSTED_RACING - Then the adapter should navigate to the hosted racing page - And the page should contain the create race button - - When I execute step 3 CREATE_RACE - Then the wizard modal should open - - When I execute step 4 RACE_INFORMATION - And I fill the session name field with "Test Race" - Then the form field should contain "Test Race" - - # ... steps 5-17 follow same pattern - - When I execute step 18 TRACK_CONDITIONS - Then the automation should stop at the safety checkpoint - And the checkout button should NOT be clicked - - Scenario: Modal step handling - Add Car modal - Given the automation is at step 8 SET_CARS - When I click the "Add Car" button - Then the ADD_CAR modal should open - When I search for "Dallara F3" - And I select the first result - Then the modal should close - And the car should be added to the selection - - Scenario: Form field validation with real selectors - Given I am on the RACE_INFORMATION page - Then the selector "input[name='sessionName']" should exist - And the selector ".form-group:has label:has-text Session Name input" should exist - - Scenario: Error handling when element not found - Given I am on a blank page - When I try to click selector "#nonexistent-element" - Then the result should indicate failure - And the error message should contain "not found" -``` - -#### Test Implementation Structure - -```typescript -// tests/e2e/docker/browserDevToolsAdapter.e2e.test.ts -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { BrowserDevToolsAdapter } from '@infrastructure/adapters/automation/BrowserDevToolsAdapter'; -import { StepId } from '@domain/value-objects/StepId'; - -describe('E2E: BrowserDevToolsAdapter - Docker Environment', () => { - let adapter: BrowserDevToolsAdapter; - const CHROME_WS_ENDPOINT = process.env.CHROME_WS_ENDPOINT || 'ws://localhost:9222'; - const FIXTURE_BASE_URL = process.env.FIXTURE_BASE_URL || 'http://localhost:3456'; - - beforeAll(async () => { - adapter = new BrowserDevToolsAdapter({ - browserWSEndpoint: CHROME_WS_ENDPOINT, - defaultTimeout: 30000, - }); - await adapter.connect(); - }); - - afterAll(async () => { - await adapter.disconnect(); - }); - - describe('Step Workflow Execution', () => { - it('should navigate to hosted racing page - step 2', async () => { - const result = await adapter.navigateToPage(`${FIXTURE_BASE_URL}/01-hosted-racing.html`); - expect(result.success).toBe(true); - }); - - it('should fill race information form - step 4', async () => { - await adapter.navigateToPage(`${FIXTURE_BASE_URL}/03-race-information.html`); - const stepId = StepId.create(4); - const result = await adapter.executeStep(stepId, { - sessionName: 'E2E Test Session', - password: 'testpass123', - description: 'Automated E2E test session', - }); - expect(result.success).toBe(true); - }); - - // ... additional step tests - }); - - describe('Modal Operations', () => { - it('should handle ADD_CAR modal - step 9', async () => { - await adapter.navigateToPage(`${FIXTURE_BASE_URL}/09-add-a-car.html`); - const stepId = StepId.create(9); - const result = await adapter.handleModal(stepId, 'open'); - expect(result.success).toBe(true); - }); - }); - - describe('Safety Checkpoint', () => { - it('should stop at step 18 without clicking checkout', async () => { - await adapter.navigateToPage(`${FIXTURE_BASE_URL}/18-track-conditions.html`); - const stepId = StepId.create(18); - const result = await adapter.executeStep(stepId, {}); - expect(result.success).toBe(true); - expect(result.metadata?.safetyStop).toBe(true); - }); - }); -}); -``` - ---- - -### Strategy B: Native macOS E2E Tests - -#### Purpose -Test OS-level screen automation on real hardware. These tests CANNOT run in Docker because native automation requires actual display access. - -#### Requirements -- macOS CI runner with display access -- Screen recording permissions granted -- Accessibility permissions enabled -- Real Chrome/browser window visible - -#### BDD Scenarios for Native E2E (Legacy) - -> Historical note: previous native OS-level automation scenarios have been retired. -> Real-world coverage is now provided by Playwright-based workflows and fixture-backed -> automation; native OS-level adapters are no longer part of the supported stack. - -#### Test Implementation Structure (Legacy) - -Previous native OS-level adapter tests have been removed. The current -E2E coverage relies on Playwright-driven automation and fixture-backed -flows as described in the Docker-based strategy above. - ---- - -### Test File Structure - -``` -tests/ -├── e2e/ -│ ├── docker/ # Docker-based E2E tests -│ │ ├── browserDevToolsAdapter.e2e.test.ts -│ │ ├── workflowSteps.e2e.test.ts -│ │ ├── modalHandling.e2e.test.ts -│ │ └── selectorValidation.e2e.test.ts -│ ├── native/ # Native OS automation tests -│ │ ├── nutJsAdapter.e2e.test.ts -│ │ ├── screenCapture.e2e.test.ts -│ │ ├── templateMatching.e2e.test.ts -│ │ └── windowFocus.e2e.test.ts -│ ├── automation.e2e.test.ts # Existing selector validation -│ └── features/ # Gherkin feature files -│ └── hosted-session-automation.feature -├── integration/ -│ └── infrastructure/ -│ └── BrowserDevToolsAdapter.test.ts -└── unit/ - └── ... - -docker/ -├── docker-compose.e2e.yml # E2E test environment -└── fixture-server/ - ├── Dockerfile - └── nginx.conf - -.github/ -└── workflows/ - ├── e2e-docker.yml # Docker E2E workflow - └── e2e-macos.yml # macOS native E2E workflow -``` - ---- - -### CI/CD Integration - -#### Docker E2E Workflow - -```yaml -# .github/workflows/e2e-docker.yml -name: E2E Tests - Docker - -on: - push: - branches: [main, develop] - pull_request: - branches: [main] - -jobs: - e2e-docker: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Start Docker E2E environment - run: | - docker compose -f docker/docker-compose.e2e.yml up -d - docker compose -f docker/docker-compose.e2e.yml ps - - - name: Wait for services to be healthy - run: | - timeout 60 bash -c 'until curl -s http://localhost:9222/json/version; do sleep 2; done' - timeout 60 bash -c 'until curl -s http://localhost:3456/01-hosted-racing.html; do sleep 2; done' - - - name: Run Docker E2E tests - run: npm run test:e2e:docker - env: - CHROME_WS_ENDPOINT: ws://localhost:9222 - FIXTURE_BASE_URL: http://localhost:3456 - - - name: Stop Docker environment - if: always() - run: docker compose -f docker/docker-compose.e2e.yml down -v -``` - -#### macOS Native E2E Workflow - -```yaml -# .github/workflows/e2e-macos.yml -name: E2E Tests - macOS Native - -on: - push: - branches: [main, develop] - pull_request: - branches: [main] - -jobs: - e2e-macos: - runs-on: macos-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Grant screen recording permissions - run: | - # Note: GitHub Actions macOS runners have limited permission support - # Some tests may be skipped if permissions cannot be granted - sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db \ - "INSERT OR REPLACE INTO access VALUES('kTCCServiceScreenCapture','com.apple.Terminal',0,2,0,1,NULL,NULL,0,'UNUSED',NULL,0,$(date +%s));" 2>/dev/null || true - - - name: Run native E2E tests - run: npm run test:e2e:native - env: - DISPLAY_AVAILABLE: "true" - - - name: Upload screenshots on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: e2e-screenshots - path: tests/e2e/native/screenshots/ -``` - ---- - -### NPM Scripts - -```json -{ - "scripts": { - "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/", - "test:e2e:native": "vitest run --config vitest.e2e.config.ts tests/e2e/native/", - "docker:e2e:up": "docker compose -f docker/docker-compose.e2e.yml up -d", - "docker:e2e:down": "docker compose -f docker/docker-compose.e2e.yml down -v", - "docker:e2e:logs": "docker compose -f docker/docker-compose.e2e.yml logs -f" - } -} -``` - ---- - -### Environment Configuration - -```bash -# .env.test.example -# Docker E2E Configuration -CHROME_WS_ENDPOINT=ws://localhost:9222 -FIXTURE_BASE_URL=http://localhost:3456 -E2E_TIMEOUT=120000 - -# Native E2E Configuration (legacy) -DISPLAY_AVAILABLE=true -``` - ---- - -## Cross-References - -- **[`ARCHITECTURE.md`](./ARCHITECTURE.md)** — Layer boundaries, port definitions, and dependency rules that guide test structure -- **[`TECH.md`](./TECH.md)** — Detailed tooling specifications (Vitest, Playwright, Testcontainers configuration) -- **[`package.json`](../package.json)** — Test scripts and commands (`test:unit`, `test:integration`, `test:e2e`, `test:coverage`) - --- ## Summary @@ -1113,180 +537,42 @@ GridPilot's testing strategy ensures: - **Infrastructure works reliably** (integration tests for repositories/adapters) - **User workflows function end-to-end** (E2E tests for full stack) - **Browser automation works correctly** (Docker E2E tests with real fixtures) -- **OS-level automation works correctly** (Native macOS E2E tests with display access) -### Hosted Session Automation Test Pyramid +### Current Test Structure (Clean Architecture) -For the iRacing hosted-session automation, confidence is provided by these concrete suites: - -- **Domain/Application unit tests** - - Entities and value objects such as [`AutomationSession`](core/domain/entities/AutomationSession.ts:1), [`SessionState`](core/domain/value-objects/SessionState.ts:1), [`CheckoutState`](core/domain/value-objects/CheckoutState.ts:1), [`CheckoutConfirmation`](core/domain/value-objects/CheckoutConfirmation.ts:1), and [`RaceCreationResult`](core/domain/value-objects/RaceCreationResult.ts:1). - - Use cases such as [`StartAutomationSessionUseCase`](core/application/use-cases/StartAutomationSessionUseCase.ts:1), [`VerifyAuthenticatedPageUseCase`](core/application/use-cases/VerifyAuthenticatedPageUseCase.ts:1), [`CompleteRaceCreationUseCase`](core/application/use-cases/CompleteRaceCreationUseCase.ts:1), and [`ConfirmCheckoutUseCase`](core/application/use-cases/ConfirmCheckoutUseCase.ts:1). - -- **Infrastructure / automation integration tests** - - Adapter wiring and fixture serving via [`FixtureServer.integration.test.ts`](tests/integration/infrastructure/FixtureServer.integration.test.ts:1). - - Playwright lifecycle and overlay wiring such as [`BrowserModeIntegration.test.ts`](tests/integration/infrastructure/BrowserModeIntegration.test.ts:1) and automation-flow tests like [`CarsFlow.integration.test.ts`](tests/integration/infrastructure/automation/CarsFlow.integration.test.ts:1). - - UI/overlay integration via [`renderer-overlay.integration.test.ts`](tests/integration/interface/renderer/renderer-overlay.integration.test.ts:1). - -- **Fixture-based step E2E tests (per-step behavior)** - - One test per wizard step under [`tests/e2e/steps`](tests/e2e/steps:1), all using the shared [`StepHarness`](tests/e2e/support/StepHarness.ts:1) and [`FixtureServer`](core/infrastructure/adapters/automation/engine/FixtureServer.ts:1). - - These validate DOM-level selectors / flows for each step (1–18) against fixture-backed HTML, and are considered canonical for step behavior. - -- **Fixture-based workflow E2E tests (button → auth entry → automation → confirmation boundary)** - - Workflow-focused tests under [`tests/e2e/workflows`](tests/e2e/workflows:1) that drive the `PlaywrightAutomationAdapter` + `WizardStepOrchestrator` across multiple steps using the fixture server. - - Example: [`steps-07-09-cars-flow.e2e.test.ts`](tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts:1) exercises cross-step cars flow, while [`full-hosted-session.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.workflow.e2e.test.ts:1) runs a full 1–18 workflow via [`MockAutomationEngineAdapter`](core/infrastructure/adapters/automation/engine/MockAutomationEngineAdapter.ts:1) and [`StartAutomationSessionUseCase`](core/application/use-cases/StartAutomationSessionUseCase.ts:1), asserting final `SessionState` and step position. - - Additional workflow scenarios cover mid-flow failure using [`MockBrowserAutomationAdapter`](core/infrastructure/adapters/automation/engine/MockBrowserAutomationAdapter.ts:1), ensuring failure states and diagnostics are surfaced without emitting false confirmations. - -- **Opt-in real-world automation smoke tests (legacy)** - - The legacy real iRacing automation suite [`automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) is now a skipped, documentation-only layer. - - It is gated by `RUN_REAL_AUTOMATION_SMOKE=1` but no longer performs native OS-level automation; real confidence comes from Playwright-based fixture and workflow suites. - -#### Confidence expectations - -- For **normal changes** to hosted-session automation (selectors, step logic, overlay behavior, authentication, or confirmation flows), the following suites must pass to claim "high confidence": - - All relevant **unit tests** in `tests/unit` that touch the changed domain/use-case code. - - All relevant **integration tests** in `tests/integration` for the affected adapters. - - All **step E2E tests** under [`tests/e2e/steps`](tests/e2e/steps:1). - - All **workflow E2E tests** under [`tests/e2e/workflows`](tests/e2e/workflows:1). - -- The **real-world smoke suite** in [`tests/e2e/automation.e2e.test.ts`](tests/e2e/automation.e2e.test.ts:1) remains as historical documentation and should not be relied upon for validating changes; instead, update and extend the Playwright-based E2E and workflow suites. - -- When adding new behavior: - - Prefer **unit tests** for domain/application changes. - - Add or extend **integration tests** when introducing new adapters or external integration. - - Add **step E2E tests** when changing DOM/step behavior for a specific wizard step. - - Add or extend **workflow E2E tests** when behavior spans multiple steps, touches authentication/session lifecycle, or affects confirmation/checkout behavior end-to-end. - -By following BDD principles and maintaining clear test organization, the team can confidently evolve GridPilot while preserving correctness and stability, with a dedicated, layered confidence story for hosted-session automation. - -## Hosted-session automation layers - -The hosted-session automation stack is covered by layered suites that balance real-site confidence with fast, deterministic fixture runs: - -- **Real-site hosted smoke (opt-in)** - - [`login-and-wizard-smoke.e2e.test.ts`](tests/e2e/hosted-real/login-and-wizard-smoke.e2e.test.ts:1) - - Gated by `HOSTED_REAL_E2E=1` and exercises the real `members.iracing.com` login + Hosted Racing landing page + "Create a Race" wizard entry. - - Fails loudly if authentication, Hosted DOM, or wizard entry regress. - -- **Fixture-backed auto-navigation workflows** - - [`full-hosted-session.autonav.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.autonav.workflow.e2e.test.ts:1) - - Uses the real Playwright stack (adapter + `WizardStepOrchestrator` + `FixtureServer`) with auto navigation enabled (`__skipFixtureNavigation` forbidden). - - Drives a representative subset of steps (e.g., 1 → 3 → 7 → 9 → 13 → 17) and asserts each step lands on the expected wizard container via [`IRACING_SELECTORS`](core/infrastructure/adapters/automation/dom/IRacingSelectors.ts:1). - -- **Step-level fixture E2Es with explicit mismatch path** - - Existing step suites under [`tests/e2e/steps`](tests/e2e/steps:1) now have two execution paths via [`StepHarness`](tests/e2e/support/StepHarness.ts:1): - - `executeStepWithFixtureMismatch()` – explicitly sets `__skipFixtureNavigation` for selector/state-mismatch tests (e.g., cars/track validation). - - `executeStepWithAutoNavigation()` – uses the adapter’s normal auto-navigation, forbidding `__skipFixtureNavigation`. - -### `__skipFixtureNavigation` guardrails - -To avoid silently masking regressions in auto navigation: - -- **Allowed (`__skipFixtureNavigation` may be set)** - - Step-level mismatch tests in [`tests/e2e/steps`](tests/e2e/steps:1) that call `executeStepWithFixtureMismatch()`, such as: - - [`step-08-cars.e2e.test.ts`](tests/e2e/steps/step-08-cars.e2e.test.ts:1) - - [`step-09-add-car.e2e.test.ts`](tests/e2e/steps/step-09-add-car.e2e.test.ts:1) - - [`step-11-track.e2e.test.ts`](tests/e2e/steps/step-11-track.e2e.test.ts:1) - -- **Forbidden (guarded; will throw if set)** - - Any suite that must exercise `PlaywrightAutomationAdapter.executeStep()` auto navigation, including: - - [`full-hosted-session.autonav.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.autonav.workflow.e2e.test.ts:1) – uses [`executeStepWithAutoNavigationGuard`](tests/e2e/support/AutoNavGuard.ts:1) and will fail if `__skipFixtureNavigation` is present in the config. - - Future workflow / overlay / validator E2Es that assert behavior across multiple steps should either: - - Use [`executeStepWithAutoNavigationGuard`](tests/e2e/support/AutoNavGuard.ts:1), or - - Call [`StepHarness.executeStepWithAutoNavigation`](tests/e2e/support/StepHarness.ts:1), which rejects configs that attempt to sneak in `__skipFixtureNavigation`. - -### Hosted-session behavior coverage matrix (initial slice) - -| Behavior | Real-site smoke | Fixture step E2Es | Fixture workflows | -|----------------------------------------------|------------------------------------------------------------------------------|--------------------------------------------------------------|-----------------------------------------------------------------------------------| -| Real login + Hosted landing | ✅ [`login-and-wizard-smoke.e2e.test.ts`](tests/e2e/hosted-real/login-and-wizard-smoke.e2e.test.ts:1) | ⛔ (fixtures only) | ⛔ (fixtures only) | -| Step 3 – Race Information DOM/fields | 🔍 via hosted wizard modal in real smoke (presence only) | ✅ [`step-03-race-information.e2e.test.ts`](tests/e2e/steps/step-03-race-information.e2e.test.ts:1) | ✅ via step 3 in [`full-hosted-session.autonav.workflow.e2e.test.ts`](tests/e2e/workflows/full-hosted-session.autonav.workflow.e2e.test.ts:1) | -| Cars / Add Car flow (steps 8–9) | 🔍 via Hosted page + Create Race modal only | ✅ [`step-08-cars.e2e.test.ts`](tests/e2e/steps/step-08-cars.e2e.test.ts:1), [`step-09-add-car.e2e.test.ts`](tests/e2e/steps/step-09-add-car.e2e.test.ts:1) | ✅ steps 7–9 in [`steps-07-09-cars-flow.e2e.test.ts`](tests/e2e/workflows/steps-07-09-cars-flow.e2e.test.ts:1) and autonav slice workflow | - -### Real-site hosted and companion workflows (opt-in) - -Real iRacing and companion-hosted workflows are **never** part of the default `npm test` run. They are gated behind explicit environment variables and npm scripts so they can be used in local runs or optional CI jobs without impacting day-to-day feedback loops. - -#### Real-site hosted smoke and focused flows - -- Smoke + wizard entry: - - [`login-and-wizard-smoke.e2e.test.ts`](tests/e2e/hosted-real/login-and-wizard-smoke.e2e.test.ts:1) -- Focused real-site wizard steps: - - [`step-03-race-information.real.e2e.test.ts`](tests/e2e/hosted-real/step-03-race-information.real.e2e.test.ts:1) - - [`cars-flow.real.e2e.test.ts`](tests/e2e/hosted-real/cars-flow.real.e2e.test.ts:1) - -Run them locally with: - -```bash -HOSTED_REAL_E2E=1 npm run test:hosted-real +**Unit Tests:** 376 files adjacent to implementation +``` +core/ + └── [domain]/[layer]/[entity]/ + ├── Entity.ts + └── Entity.test.ts ✅ ``` -Intended CI usage: - -- Optional nightly/weekly workflow (not per-commit). -- Example job shape: - - - Checkout - - `npm ci` - - `HOSTED_REAL_E2E=1 npm run test:hosted-real` - -#### Companion fixture-hosted workflow (opt-in) - -- Companion-hosted workflow over fixtures: - - [`companion-ui-full-workflow.e2e.test.ts`](tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts:1) - -Run it locally with: - -```bash -COMPANION_FIXTURE_HOSTED=1 npm run test:companion-hosted +**Integration Tests:** In-memory only +``` +tests/integration/ +├── racing/RegistrationAndTeamUseCases.test.ts +└── website/ + ├── auth-flow.test.ts + ├── auth-guard.test.ts + └── middleware.test.ts ``` -Intended CI usage: +**E2E Tests:** TypeORM/PostgreSQL via Docker +``` +tests/e2e/ +└── website/website-pages.test.ts +``` -- Optional companion-centric workflow (nightly or on-demand). -- Example job shape: +**Testing Infrastructure:** Minimal, in `adapters/testing/` (only what's actually used) - - Checkout - - `npm ci` - - `COMPANION_FIXTURE_HOSTED=1 npm run test:companion-hosted` +### Key Principles -These suites assume the same fixture server and Playwright wiring as the rest of the hosted-session tests and are explicitly **opt-in** so `npm test` remains fast and deterministic. +1. **Domain layer has ZERO dependencies** +2. **Unit tests live adjacent to code** +3. **Integration tests use in-memory only** +4. **E2E tests use TypeORM/PostgreSQL** +5. **No loose functions - everything is classes or inline factories** +6. **Enforced via TypeScript paths and ESLint rules** -#### Selector ↔ fixture ↔ real DOM guardrail - -For hosted-session automation, [`IRACING_SELECTORS`](core/infrastructure/adapters/automation/dom/IRacingSelectors.ts:1) must match **either**: - -- The current `html-dumps-optimized` fixtures under [`html-dumps-optimized/iracing-hosted-sessions`](html-dumps-optimized/iracing-hosted-sessions:1), or -- The real-site DOM as exercised by the hosted-real tests above. - -Manual workflow when the iRacing DOM changes: - -1. Detect failure: - - - A hosted-real test fails because a selector no longer matches, or - - A fixture-backed step/workflow test fails in a way that suggests large DOM drift. - -2. Refresh DOM fixtures: - - ```bash - npm run export-html-dumps - ``` - - This script runs [`exportHtmlDumps.ts`](scripts/dom-export/exportHtmlDumps.ts:1) to regenerate `html-dumps-optimized` from the raw HTML under [`html-dumps`](html-dumps:1). - -3. Re-align selectors and tests: - - - Update [`IRACING_SELECTORS`](core/infrastructure/adapters/automation/dom/IRacingSelectors.ts:1) to reflect the new DOM shape. - - Fix any failing step/workflow E2Es under [`tests/e2e/steps`](tests/e2e/steps:1) and [`tests/e2e/workflows`](tests/e2e/workflows:1) so they again describe the canonical behavior. - - Re-run: - - `npm test` - - `HOSTED_REAL_E2E=1 npm run test:hosted-real` (if access to real iRacing) - - `COMPANION_FIXTURE_HOSTED=1 npm run test:companion-hosted` (optional) - -This keeps fixtures, selectors, and real-site behavior aligned without forcing real-site tests into every CI run. - -The intent for new hosted-session work is: - -- Use fixture-backed **step E2Es** to lock DOM and per-step behavior. -- Use fixture-backed **auto-navigation workflows** to guard `WizardStepOrchestrator` and `PlaywrightAutomationAdapter.executeStep()` across multiple steps. -- Use **opt-in real-site smoke** to catch drift in authentication and Hosted Racing DOM without impacting default CI. +This structure ensures clean architecture compliance while maintaining comprehensive test coverage. \ No newline at end of file diff --git a/testing/factories/racing/ChampionshipConfigFactory.ts b/testing/factories/racing/ChampionshipConfigFactory.ts deleted file mode 100644 index 22be44b7c..000000000 --- a/testing/factories/racing/ChampionshipConfigFactory.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { SessionType } from '@core/racing/domain/types/SessionType'; -import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; -import type { BonusRule } from '@core/racing/domain/types/BonusRule'; -import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; -import { makePointsTable } from './PointsTableFactory'; - -export const makeChampionshipConfig = (params: { - id: string; - name: string; - sessionTypes: SessionType[]; - mainPoints: number[]; - sprintPoints?: number[]; - mainBonusRules?: BonusRule[]; -}): ChampionshipConfig => { - const { id, name, sessionTypes, mainPoints, sprintPoints, mainBonusRules } = params; - - const pointsTableBySessionType: Record = {} as Record; - - sessionTypes.forEach((sessionType) => { - if (sessionType === 'main') { - pointsTableBySessionType[sessionType] = makePointsTable(mainPoints); - } else if (sessionType === 'sprint' && sprintPoints) { - pointsTableBySessionType[sessionType] = makePointsTable(sprintPoints); - } else { - pointsTableBySessionType[sessionType] = new PointsTable({}); - } - }); - - const bonusRulesBySessionType: Record = {} as Record; - sessionTypes.forEach((sessionType) => { - if (sessionType === 'main' && mainBonusRules) { - bonusRulesBySessionType[sessionType] = mainBonusRules; - } else { - bonusRulesBySessionType[sessionType] = []; - } - }); - - return { - id, - name, - type: 'driver', - sessionTypes, - pointsTableBySessionType, - bonusRulesBySessionType, - dropScorePolicy: { - strategy: 'none', - }, - }; -}; \ No newline at end of file diff --git a/testing/factories/racing/DriverRefFactory.ts b/testing/factories/racing/DriverRefFactory.ts deleted file mode 100644 index e0944f6e1..000000000 --- a/testing/factories/racing/DriverRefFactory.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef'; -import type { ChampionshipType } from '@core/racing/domain/types/ChampionshipType'; -import { MediaReference } from '../../../core/domain/media/MediaReference'; - -export const makeDriverRef = (id: string): ParticipantRef => ({ - type: 'driver' as ChampionshipType, - id, - avatarRef: MediaReference.systemDefault('avatar'), -}); \ No newline at end of file diff --git a/testing/factories/racing/PointsTableFactory.ts b/testing/factories/racing/PointsTableFactory.ts deleted file mode 100644 index 28a52a892..000000000 --- a/testing/factories/racing/PointsTableFactory.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; - -export const makePointsTable = (points: number[]): PointsTable => { - const pointsByPosition: Record = {}; - points.forEach((value, index) => { - pointsByPosition[index + 1] = value; - }); - return new PointsTable(pointsByPosition); -}; \ No newline at end of file diff --git a/testing/factories/racing/SeasonFactory.ts b/testing/factories/racing/SeasonFactory.ts deleted file mode 100644 index 0437ccbcd..000000000 --- a/testing/factories/racing/SeasonFactory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Season } from '@core/racing/domain/entities/season/Season'; -import type { SeasonStatus } from '@core/racing/domain/entities/season/Season'; - -export const createMinimalSeason = (overrides?: { status?: SeasonStatus }) => - Season.create({ - id: 'season-1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Test Season', - status: overrides?.status ?? 'planned', - }); - -export const createBaseSeason = () => - Season.create({ - id: 'season-1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Config Season', - status: 'planned', - startDate: new Date('2025-01-01T00:00:00Z'), - endDate: undefined, - maxDrivers: 24, - }); \ No newline at end of file diff --git a/testing/fakes/identity/IracingDemoIdentityProviderAdapter.ts b/testing/fakes/identity/IracingDemoIdentityProviderAdapter.ts deleted file mode 100644 index 0fb3bbe73..000000000 --- a/testing/fakes/identity/IracingDemoIdentityProviderAdapter.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { randomUUID } from 'crypto'; -import type { - AuthCallbackCommand, - AuthenticatedUser, - IdentityProviderPort, - StartAuthCommand, -} from '@core/identity/application/ports/IdentityProviderPort'; - -export class IracingDemoIdentityProviderAdapter implements IdentityProviderPort { - private readonly seedDriverId: string; - - constructor() { - this.seedDriverId = 'driver-1'; - } - - async startAuth(command: StartAuthCommand): Promise<{ redirectUrl: string; state: string }> { - const state = randomUUID(); - - const params = new URLSearchParams(); - params.set('code', 'dummy-code'); - params.set('state', state); - if (command.returnTo) { - params.set('returnTo', command.returnTo); - } - - return { - redirectUrl: `/auth/iracing/callback?${params.toString()}`, - state, - }; - } - - async completeAuth(command: AuthCallbackCommand): Promise { - if (!command.code) { - throw new Error('Missing auth code'); - } - if (!command.state) { - throw new Error('Missing auth state'); - } - - const user: AuthenticatedUser = { - id: 'demo-user', - displayName: 'GridPilot Demo Driver', - iracingCustomerId: '000000', - primaryDriverId: this.seedDriverId, - avatarUrl: `/api/avatar/${this.seedDriverId}`, - }; - - return user; - } -} \ No newline at end of file diff --git a/testing/fakes/racing/DemoCars.ts b/testing/fakes/racing/DemoCars.ts deleted file mode 100644 index f1ffe55d0..000000000 --- a/testing/fakes/racing/DemoCars.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Car } from '@core/racing/domain/entities/Car'; - -/** - * Demo car data for iRacing. - * Extracted from the legacy DemoData module so that cars - * live in their own focused file. - */ -export const DEMO_CARS: Car[] = [ - Car.create({ - id: 'car-porsche-992', - name: '911 GT3 R', - shortName: '992 GT3R', - manufacturer: 'Porsche', - carClass: 'gt', - license: 'B', - year: 2023, - horsepower: 565, - weight: 1300, - gameId: 'iracing', - }), - Car.create({ - id: 'car-ferrari-296', - name: '296 GT3', - shortName: '296 GT3', - manufacturer: 'Ferrari', - carClass: 'gt', - license: 'B', - year: 2023, - horsepower: 600, - weight: 1270, - gameId: 'iracing', - }), - Car.create({ - id: 'car-mclaren-720s', - name: '720S GT3 Evo', - shortName: '720S', - manufacturer: 'McLaren', - carClass: 'gt', - license: 'B', - year: 2023, - horsepower: 552, - weight: 1290, - gameId: 'iracing', - }), - Car.create({ - id: 'car-mercedes-gt3', - name: 'AMG GT3 2020', - shortName: 'AMG GT3', - manufacturer: 'Mercedes', - carClass: 'gt', - license: 'B', - year: 2020, - horsepower: 550, - weight: 1285, - gameId: 'iracing', - }), - Car.create({ - id: 'car-lmp2', - name: 'Dallara P217 LMP2', - shortName: 'LMP2', - manufacturer: 'Dallara', - carClass: 'prototype', - license: 'A', - year: 2021, - horsepower: 600, - weight: 930, - gameId: 'iracing', - }), - Car.create({ - id: 'car-f4', - name: 'Formula 4', - shortName: 'F4', - manufacturer: 'Tatuus', - carClass: 'formula', - license: 'D', - year: 2022, - horsepower: 160, - weight: 570, - gameId: 'iracing', - }), - Car.create({ - id: 'car-mx5', - name: 'MX-5 Cup', - shortName: 'MX5', - manufacturer: 'Mazda', - carClass: 'sports', - license: 'D', - year: 2023, - horsepower: 181, - weight: 1128, - gameId: 'iracing', - }), -]; \ No newline at end of file diff --git a/testing/fakes/racing/DemoDriverStats.ts b/testing/fakes/racing/DemoDriverStats.ts deleted file mode 100644 index 0f6984237..000000000 --- a/testing/fakes/racing/DemoDriverStats.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Driver statistics and ranking data used for demo seeding. - * Split out from the legacy DemoData module to keep responsibilities focused. - */ -export interface DriverStats { - driverId: string; - rating: number; - totalRaces: number; - wins: number; - podiums: number; - dnfs: number; - avgFinish: number; - bestFinish: number; - worstFinish: number; - consistency: number; - overallRank: number; - percentile: number; -} - -/** - * Create demo driver statistics based on seed data. - * This is deterministic for a given driver ordering so it can be reused - * by any in-memory repository wiring. - */ -export function createDemoDriverStats(drivers: Array<{ id: string }>): Record { - const stats: Record = {}; - - drivers.forEach((driver, index) => { - const totalRaces = 40 + index * 5; - const wins = Math.max(0, Math.floor(totalRaces * 0.2) - index); - const podiums = Math.max(wins * 2, 0); - const dnfs = Math.max(0, Math.floor(index / 2)); - const rating = 1500 + index * 25; - - stats[driver.id] = { - driverId: driver.id, - rating, - totalRaces, - wins, - podiums, - dnfs, - avgFinish: 4, - bestFinish: 1, - worstFinish: 20, - consistency: 80, - overallRank: index + 1, - percentile: Math.max(0, 100 - index), - }; - }); - - return stats; -} - -/** - * Get league-specific rankings for a driver (demo implementation). - * In production this would be calculated from actual league membership - * and results; here we keep a very small static example for UI wiring. - */ -export function getDemoLeagueRankings(driverId: string, leagueId: string): { - rank: number; - totalDrivers: number; - percentile: number; -} { - // Mock league rankings (in production, calculate from actual league membership) - const mockLeagueRanks: Record> = { - 'league-1': { - 'driver-1': { rank: 1, totalDrivers: 12, percentile: 92 }, - 'driver-2': { rank: 2, totalDrivers: 12, percentile: 84 }, - 'driver-3': { rank: 4, totalDrivers: 12, percentile: 67 }, - 'driver-4': { rank: 5, totalDrivers: 12, percentile: 58 }, - }, - }; - - return mockLeagueRanks[leagueId]?.[driverId] || { rank: 0, totalDrivers: 0, percentile: 0 }; -} \ No newline at end of file diff --git a/testing/fakes/racing/DemoTracks.ts b/testing/fakes/racing/DemoTracks.ts deleted file mode 100644 index 24c93ca8d..000000000 --- a/testing/fakes/racing/DemoTracks.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Track } from '@core/racing/domain/entities/Track'; - -/** - * Demo track data for iRacing. - * Extracted from the legacy DemoData module so that tracks - * live in their own focused file. - */ -export const DEMO_TRACKS: Track[] = [ - Track.create({ - id: 'track-spa', - name: 'Spa-Francorchamps', - shortName: 'SPA', - country: 'Belgium', - category: 'road', - difficulty: 'advanced', - lengthKm: 7.004, - turns: 19, - imageUrl: '/images/tracks/spa.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-monza', - name: 'Autodromo Nazionale Monza', - shortName: 'MON', - country: 'Italy', - category: 'road', - difficulty: 'intermediate', - lengthKm: 5.793, - turns: 11, - imageUrl: '/images/tracks/monza.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-nurburgring', - name: 'Nürburgring Grand Prix', - shortName: 'NUR', - country: 'Germany', - category: 'road', - difficulty: 'advanced', - lengthKm: 5.148, - turns: 15, - imageUrl: '/images/tracks/nurburgring.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-silverstone', - name: 'Silverstone Circuit', - shortName: 'SIL', - country: 'United Kingdom', - category: 'road', - difficulty: 'intermediate', - lengthKm: 5.891, - turns: 18, - imageUrl: '/images/tracks/silverstone.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-suzuka', - name: 'Suzuka International Racing Course', - shortName: 'SUZ', - country: 'Japan', - category: 'road', - difficulty: 'expert', - lengthKm: 5.807, - turns: 18, - imageUrl: '/images/tracks/suzuka.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-daytona', - name: 'Daytona International Speedway', - shortName: 'DAY', - country: 'United States', - category: 'oval', - difficulty: 'intermediate', - lengthKm: 4.023, - turns: 4, - imageUrl: '/images/tracks/daytona.jpg', - gameId: 'iracing', - }), - Track.create({ - id: 'track-laguna', - name: 'WeatherTech Raceway Laguna Seca', - shortName: 'LAG', - country: 'United States', - category: 'road', - difficulty: 'advanced', - lengthKm: 3.602, - turns: 11, - imageUrl: '/images/tracks/laguna.jpg', - gameId: 'iracing', - }), -]; \ No newline at end of file diff --git a/testing/fixtures/racing/RacingFeedSeed.ts b/testing/fixtures/racing/RacingFeedSeed.ts deleted file mode 100644 index 82de2ee73..000000000 --- a/testing/fixtures/racing/RacingFeedSeed.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { Driver } from '@core/racing/domain/entities/Driver'; -import { League } from '@core/racing/domain/entities/League'; -import { Race } from '@core/racing/domain/entities/Race'; -import type { Result } from '@core/racing/domain/entities/Result'; -import type { FeedItem } from '@core/social/domain/types/FeedItem'; -import type { SocialFriendSummary } from '@core/social/application/types/SocialUser'; -import { faker } from '../../helpers/faker/faker'; -import { getLeagueBanner, getDriverAvatar } from '../../helpers/images/images'; -import type { Friendship, RacingMembership } from './RacingSeedCore'; - -/** - * Feed events and derived racing demo data. - * Extracted from the legacy StaticRacingSeed module to keep files smaller. - */ -export function createFeedEvents( - drivers: Driver[], - leagues: League[], - races: Race[], - friendships: Friendship[], -): FeedItem[] { - const events: FeedItem[] = []; - const now = new Date(); - const completedRaces = races.filter((race) => race.status === 'completed'); - - // Focus the global feed around a stable “core” of demo drivers - const coreDrivers = faker.helpers.shuffle(drivers).slice(0, Math.min(16, drivers.length)); - - coreDrivers.forEach((driver, index) => { - const league = pickOne(leagues); - const raceSource = completedRaces.length > 0 ? completedRaces : races; - const race = pickOne(raceSource); - const minutesAgo = 10 + index * 5; - const baseTimestamp = new Date(now.getTime() - minutesAgo * 60 * 1000); - - const actorFriendId = driver.id; - - // Joined league - events.push({ - id: `friend-joined-league:${driver.id}:${minutesAgo}`, - type: 'friend-joined-league', - timestamp: baseTimestamp, - actorDriverId: driver.id, - actorFriendId, - leagueId: league.id, - headline: `${driver.name} joined ${league.name}`, - body: 'They are now registered for the full season.', - ctaLabel: 'View league', - ctaHref: `/leagues/${league.id}`, - }); - - // Finished race / podium highlight - const finishingPosition = (index % 5) + 1; - events.push({ - id: `friend-finished-race:${driver.id}:${minutesAgo}`, - type: 'friend-finished-race', - timestamp: new Date(baseTimestamp.getTime() - 8 * 60 * 1000), - actorDriverId: driver.id, - actorFriendId, - leagueId: race.leagueId, - raceId: race.id, - position: finishingPosition, - headline: `${driver.name} finished P${finishingPosition} at ${race.track}`, - body: - finishingPosition <= 3 - ? `${driver.name} scored a podium in ${race.car}.` - : `${driver.name} secured a strong result in ${race.car}.`, - ctaLabel: 'View results', - ctaHref: `/races/${race.id}/results`, - }); - - // New personal best - events.push({ - id: `friend-new-personal-best:${driver.id}:${minutesAgo}`, - type: 'friend-new-personal-best', - timestamp: new Date(baseTimestamp.getTime() - 20 * 60 * 1000), - actorDriverId: driver.id, - actorFriendId, - leagueId: race.leagueId, - raceId: race.id, - headline: `${driver.name} set a new personal best at ${race.track}`, - body: 'Consistency and pace are trending up this season.', - ctaLabel: 'View lap chart', - ctaHref: `/races/${race.id}/analysis`, - }); - - // Joined team (where applicable) - const driverFriendships = friendships.filter((f) => f.driverId === driver.id); - if (driverFriendships.length > 0) { - const friend = pickOne(driverFriendships); - const teammate = drivers.find((d) => d.id === friend.friendId); - if (teammate) { - events.push({ - id: `friend-joined-team:${driver.id}:${minutesAgo}`, - type: 'friend-joined-team', - timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000), - actorDriverId: driver.id, - actorFriendId, - headline: `${driver.name} and ${teammate.name} are now teammates`, - body: 'They will be sharing strategy and setups this season.', - ctaLabel: 'View team', - ctaHref: '/teams', - }); - } - } - - // League highlight - events.push({ - id: `league-highlight:${league.id}:${minutesAgo}`, - type: 'league-highlight', - timestamp: new Date(baseTimestamp.getTime() - 45 * 60 * 1000), - leagueId: league.id, - headline: `${league.name} active with ${drivers.length}+ drivers`, - body: 'Participation is growing. Perfect time to join the grid.', - ctaLabel: 'Explore league', - ctaHref: `/leagues/${league.id}`, - }); - }); - - // Global “system” events: new race scheduled and results posted - const upcomingRaces = races.filter((race) => race.status === 'scheduled').slice(0, 8); - upcomingRaces.forEach((race, index) => { - const minutesAgo = 60 + index * 15; - const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000); - events.push({ - id: `new-race-scheduled:${race.id}`, - type: 'new-race-scheduled', - timestamp, - leagueId: race.leagueId, - raceId: race.id, - headline: `New race scheduled at ${race.track}`, - body: `${race.car} • ${race.scheduledAt.toLocaleString()}`, - ctaLabel: 'View schedule', - ctaHref: `/races/${race.id}`, - }); - }); - - const completedForResults = completedRaces.slice(0, 8); - completedForResults.forEach((race, index) => { - const minutesAgo = 180 + index * 20; - const timestamp = new Date(now.getTime() - minutesAgo * 60 * 1000); - events.push({ - id: `new-result-posted:${race.id}`, - type: 'new-result-posted', - timestamp, - leagueId: race.leagueId, - raceId: race.id, - headline: `Results posted for ${race.track}`, - body: 'Standings and stats updated across the grid.', - ctaLabel: 'View classification', - ctaHref: `/races/${race.id}/results`, - }); - }); - - const sorted = events - .slice() - .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - - return sorted; -} - -/** - * Derived friend DTOs for UI consumption. - * This preserves the previous demo-data `friends` shape while - * keeping generation logic separate from the main seed. - */ -export function buildFriends( - drivers: Driver[], - memberships: RacingMembership[], -): SocialFriendSummary[] { - return drivers.map((driver) => { - const membership = memberships.find((m) => m.driverId === driver.id); - - const base: SocialFriendSummary = { - driverId: driver.id, - displayName: driver.name, - avatarUrl: getDriverAvatar(driver.id), - isOnline: true, - lastSeen: new Date(), - }; - - const withLeague = - membership?.leagueId !== undefined - ? { ...base, primaryLeagueId: membership.leagueId } - : base; - - const withTeam = - membership?.teamId !== undefined - ? { ...withLeague, primaryTeamId: membership.teamId } - : withLeague; - - return withTeam; - }); -} - -/** - * Build top leagues with banner URLs for UI. - */ -export type LeagueWithBannerDTO = { - id: string; - name: string; - description: string; - ownerId: string; - settings: League['settings']; - createdAt: Date; - socialLinks: League['socialLinks']; - bannerUrl: string; -}; - -export function buildTopLeagues(leagues: League[]): LeagueWithBannerDTO[] { - return leagues.map((league) => ({ - id: league.id, - name: league.name, - description: league.description, - ownerId: league.ownerId, - settings: league.settings, - createdAt: league.createdAt, - socialLinks: league.socialLinks, - bannerUrl: getLeagueBanner(league.id), - })); -} - -export type RaceWithResultsDTO = { - raceId: string; - track: string; - car: string; - scheduledAt: Date; - winnerDriverId: string; - winnerName: string; -}; - -/** - * Utility to get upcoming races from a given race list. - */ -export function buildUpcomingRaces( - races: Race[], - limit?: number, -): readonly Race[] { - const upcoming = races.filter((race) => race.status === 'scheduled'); - const sorted = upcoming - .slice() - .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); - return typeof limit === 'number' ? sorted.slice(0, limit) : sorted; -} - -/** - * Utility to get latest race results from races + results + drivers. - */ -export function buildLatestResults( - races: Race[], - results: Result[], - drivers: Driver[], - limit?: number, -): readonly RaceWithResultsDTO[] { - const completedRaces = races.filter((race) => race.status === 'completed'); - - const joined = completedRaces.map((race) => { - const raceResults = results - .filter((result) => result.raceId === race.id) - .slice() - .sort((a, b) => a.position - b.position); - const winner = raceResults[0]; - const winnerDriver = - winner && drivers.find((driver) => driver.id === winner.driverId); - - return { - raceId: race.id, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - winnerDriverId: winner?.driverId ?? '', - winnerName: winnerDriver?.name ?? 'Winner', - }; - }); - - const sorted = joined - .slice() - .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()); - - return typeof limit === 'number' ? sorted.slice(0, limit) : sorted; -} - -/** - * Local helper: random pick from an array. - * Kept here to avoid importing from core in callers that only care about feed. - */ -function pickOne(items: readonly T[]): T { - if (items.length === 0) { - throw new Error('pickOne: empty items array'); - } - const index = faker.number.int({ min: 0, max: items.length - 1 }); - return items[index]!; -} \ No newline at end of file diff --git a/testing/fixtures/racing/RacingSeedCore.ts b/testing/fixtures/racing/RacingSeedCore.ts deleted file mode 100644 index 3bee71e7b..000000000 --- a/testing/fixtures/racing/RacingSeedCore.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { Driver } from '@core/racing/domain/entities/Driver'; -import { League } from '@core/racing/domain/entities/League'; -import { Race } from '@core/racing/domain/entities/Race'; -import { Result } from '@core/racing/domain/entities/Result'; -import { Standing } from '@core/racing/domain/entities/Standing'; -import { SessionType } from '@core/racing/domain/value-objects/SessionType'; -import { faker } from '../../helpers/faker/faker'; - -/** - * Core racing seed types and generators (drivers, leagues, teams, races, standings). - * Extracted from the legacy StaticRacingSeed module to keep files smaller and focused. - */ -export type RacingMembership = { - driverId: string; - leagueId: string; - teamId?: string; -}; - -export type Friendship = { - driverId: string; - friendId: string; -}; - -import { MediaReference } from '@core/domain/media/MediaReference'; - -export interface DemoTeamDTO { - id: string; - name: string; - tag: string; - description: string; - logoRef: MediaReference; - primaryLeagueId: string; - memberCount: number; -} - -/** - * Championship points table used when aggregating standings. - */ -export const POINTS_TABLE: Record = { - 1: 25, - 2: 18, - 3: 15, - 4: 12, - 5: 10, - 6: 8, - 7: 6, - 8: 4, - 9: 2, - 10: 1, -}; - -export function pickOne(items: readonly T[]): T { - if (items.length === 0) { - throw new Error('pickOne: empty items array'); - } - const index = faker.number.int({ min: 0, max: items.length - 1 }); - return items[index]!; -} - -export function createDrivers(count: number): Driver[] { - const drivers: Driver[] = []; - - for (let i = 0; i < count; i++) { - const id = `driver-${i + 1}`; - const name = faker.person.fullName(); - const country = faker.location.countryCode('alpha-2'); - const iracingId = faker.string.numeric(6); - - drivers.push( - Driver.create({ - id, - iracingId, - name, - country, - bio: faker.lorem.sentence(), - joinedAt: faker.date.past(), - }), - ); - } - - return drivers; -} - -export function createLeagues(ownerIds: string[]): League[] { - const leagueNames = [ - 'GridPilot Sprint Series', - 'GridPilot Endurance Cup', - 'GridPilot Club Ladder', - 'Sprint Challenge League', - 'Club Racers Collective', - 'Sim Racing Alliance', - 'Pacific Time Attack', - 'Nordic Night Series', - ]; - - const leagues: League[] = []; - const leagueCount = 6 + faker.number.int({ min: 0, max: 2 }); - - for (let i = 0; i < leagueCount; i++) { - const id = `league-${i + 1}`; - const name = leagueNames[i] ?? faker.company.name(); - // Ensure league-5 (demo league with running race) is owned by driver-1 - const ownerId = i === 4 ? 'driver-1' : pickOne(ownerIds); - - const maxDriversOptions = [24, 32, 48, 64]; - let settings = { - pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']), - sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]), - qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']), - maxDrivers: faker.helpers.arrayElement(maxDriversOptions), - } as const; - - if (i === 0) { - settings = { - ...settings, - maxDrivers: 24, - }; - } else if (i === 1) { - settings = { - ...settings, - maxDrivers: 24, - }; - } else if (i === 2) { - settings = { - ...settings, - maxDrivers: 40, - }; - } - - const socialLinks = - i === 0 - ? { - discordUrl: 'https://discord.gg/gridpilot-demo', - youtubeUrl: 'https://youtube.com/@gridpilot-demo', - websiteUrl: 'https://gridpilot-demo.example.com', - } - : i === 1 - ? { - discordUrl: 'https://discord.gg/gridpilot-endurance', - youtubeUrl: 'https://youtube.com/@gridpilot-endurance', - } - : i === 2 - ? { - websiteUrl: 'https://virtual-touring.example.com', - } - : undefined; - - if (socialLinks) { - leagues.push( - League.create({ - id, - name, - description: faker.lorem.sentence(), - ownerId, - settings, - createdAt: faker.date.past(), - socialLinks, - }), - ); - } else { - leagues.push( - League.create({ - id, - name, - description: faker.lorem.sentence(), - ownerId, - settings, - createdAt: faker.date.past(), - }), - ); - } - } - - return leagues; -} - -export function createTeams(leagues: League[]): DemoTeamDTO[] { - const teams: DemoTeamDTO[] = []; - const teamCount = 24 + faker.number.int({ min: 0, max: 12 }); - - for (let i = 0; i < teamCount; i++) { - const id = `team-${i + 1}`; - const primaryLeague = pickOne(leagues); - const name = faker.company.name(); - const tag = faker.string.alpha({ length: 4 }).toUpperCase(); - const memberCount = faker.number.int({ min: 2, max: 8 }); - - teams.push({ - id, - name, - tag, - description: faker.lorem.sentence(), - logoRef: MediaReference.systemDefault('logo'), - primaryLeagueId: primaryLeague.id, - memberCount, - }); - } - - return teams; -} - -export function createMemberships( - drivers: Driver[], - leagues: League[], - teams: DemoTeamDTO[], -): RacingMembership[] { - const memberships: RacingMembership[] = []; - - const teamsByLeague = new Map(); - teams.forEach((team) => { - const list = teamsByLeague.get(team.primaryLeagueId) ?? []; - list.push(team); - teamsByLeague.set(team.primaryLeagueId, list); - }); - - - drivers.forEach((driver) => { - // Each driver participates in 1–3 leagues - const leagueSampleSize = faker.number.int({ min: 1, max: Math.min(3, leagues.length) }); - const shuffledLeagues = faker.helpers.shuffle(leagues).slice(0, leagueSampleSize); - - shuffledLeagues.forEach((league) => { - const leagueTeams = teamsByLeague.get(league.id) ?? []; - const team = - leagueTeams.length > 0 && faker.datatype.boolean() - ? pickOne(leagueTeams) - : undefined; - - const membership: RacingMembership = { - driverId: driver.id, - leagueId: league.id, - }; - - if (team) { - membership.teamId = team.id; - } - - memberships.push(membership); - }); - }); - - return memberships; -} - -export function createRaces(leagues: League[]): Race[] { - const races: Race[] = []; - const raceCount = 60 + faker.number.int({ min: 0, max: 20 }); - - const tracks = [ - 'Monza GP', - 'Spa-Francorchamps', - 'Suzuka', - 'Mount Panorama', - 'Silverstone GP', - 'Interlagos', - 'Imola', - 'Laguna Seca', - ]; - - const cars = [ - 'GT3 – Porsche 911', - 'GT3 – BMW M4', - 'LMP3 Prototype', - 'GT4 – Alpine', - 'Touring – Civic', - ]; - - const baseDate = new Date(); - - for (let i = 0; i < raceCount; i++) { - const id = `race-${i + 1}`; - let league = pickOne(leagues); - const offsetDays = faker.number.int({ min: -30, max: 45 }); - const scheduledAt = new Date(baseDate.getTime() + offsetDays * 24 * 60 * 60 * 1000); - let status: 'scheduled' | 'completed' | 'running' = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled'; - let strengthOfField: number | undefined; - - // Special case: Make race-1 a running race in league-5 (user's admin league) - if (i === 0) { - const league5 = leagues.find(l => l.id === 'league-5'); - if (league5) { - league = league5; - status = 'running'; - // Calculate SOF for the running race (simulate 12-20 drivers with average rating ~1500) - const participantCount = faker.number.int({ min: 12, max: 20 }); - const averageRating = 1500 + faker.number.int({ min: -200, max: 300 }); - strengthOfField = Math.round(averageRating); - } - } - - races.push( - Race.create({ - id, - leagueId: league.id, - scheduledAt, - track: faker.helpers.arrayElement(tracks), - car: faker.helpers.arrayElement(cars), - sessionType: SessionType.main(), - status, - ...(strengthOfField !== undefined ? { strengthOfField } : {}), - ...(status === 'running' ? { registeredCount: faker.number.int({ min: 12, max: 20 }) } : {}), - }), - ); - } - - return races; -} - -export function createResults(drivers: Driver[], races: Race[]): Result[] { - const results: Result[] = []; - - const completedRaces = races.filter((race) => race.status === 'completed'); - - completedRaces.forEach((race) => { - const participantCount = faker.number.int({ min: 20, max: 32 }); - const shuffledDrivers = faker.helpers.shuffle(drivers).slice(0, participantCount); - - shuffledDrivers.forEach((driver, index) => { - const position = index + 1; - const startPosition = faker.number.int({ min: 1, max: participantCount }); - const fastestLap = 90_000 + index * 250 + faker.number.int({ min: 0, max: 2_000 }); - const incidents = faker.number.int({ min: 0, max: 6 }); - - results.push( - Result.create({ - id: `${race.id}-${driver.id}`, - raceId: race.id, - driverId: driver.id, - position, - startPosition, - fastestLap, - incidents, - }), - ); - }); - }); - - return results; -} - -export function createStandings(leagues: League[], results: Result[]): Standing[] { - const standingsByLeague = new Map(); - - leagues.forEach((league) => { - const leagueRaceIds = new Set( - results - .filter((result) => { - return result.raceId.startsWith('race-'); - }) - .map((result) => result.raceId), - ); - - const leagueResults = results.filter((result) => leagueRaceIds.has(result.raceId)); - - const standingsMap = new Map(); - - leagueResults.forEach((result) => { - const key = result.driverId; - let standing = standingsMap.get(key); - - if (!standing) { - standing = Standing.create({ - leagueId: league.id, - driverId: result.driverId, - }); - } - - standing = standing.addRaceResult(result.position, POINTS_TABLE); - standingsMap.set(key, standing); - }); - - const sortedStandings = Array.from(standingsMap.values()).sort((a, b) => { - if (b.points !== a.points) { - return b.points - a.points; - } - if (b.wins !== a.wins) { - return b.wins - a.wins; - } - return b.racesCompleted - a.racesCompleted; - }); - - const finalizedStandings = sortedStandings.map((standing, index) => - standing.updatePosition(index + 1), - ); - - standingsByLeague.set(league.id, finalizedStandings); - }); - - return Array.from(standingsByLeague.values()).flat(); -} - -export function createFriendships(drivers: Driver[]): Friendship[] { - const friendships: Friendship[] = []; - - drivers.forEach((driver, index) => { - const friendCount = faker.number.int({ min: 3, max: 8 }); - for (let offset = 1; offset <= friendCount; offset++) { - const friendIndex = (index + offset) % drivers.length; - const friend = drivers[friendIndex]; - if (!friend) continue; - if (friend.id === driver.id) continue; - - friendships.push({ - driverId: driver.id, - friendId: friend.id, - }); - } - }); - - return friendships; -} \ No newline at end of file diff --git a/testing/fixtures/racing/RacingSponsorshipSeed.ts b/testing/fixtures/racing/RacingSponsorshipSeed.ts deleted file mode 100644 index 21c6cdf9c..000000000 --- a/testing/fixtures/racing/RacingSponsorshipSeed.ts +++ /dev/null @@ -1,451 +0,0 @@ -import { League } from '@core/racing/domain/entities/League'; -import { Race } from '@core/racing/domain/entities/Race'; -import { Driver } from '@core/racing/domain/entities/Driver'; -import { Money } from '@core/racing/domain/value-objects/Money'; -import { SponsorshipPricing } from '@core/racing/domain/value-objects/SponsorshipPricing'; -import { - SponsorshipRequest, - type SponsorableEntityType, -} from '@core/racing/domain/entities/SponsorshipRequest'; - -import type { DemoTeamDTO } from './RacingSeedCore'; - -/** - * Demo sponsor data for seeding. - */ -export interface DemoSponsorDTO { - id: string; - name: string; - contactEmail: string; - logoUrl: string; - websiteUrl: string; - tagline: string; -} - -/** - * Demo season sponsorship data. - * This remains a simple DTO since the SeasonSponsorship - * domain entity is instantiated in the DI config. - */ -export interface DemoSeasonSponsorshipDTO { - id: string; - seasonId: string; - sponsorId: string; - tier: 'main' | 'secondary'; - pricingAmount: number; - pricingCurrency: 'USD' | 'EUR' | 'GBP'; - status: 'pending' | 'active' | 'cancelled'; - description?: string; -} - -/** - * Demo sponsorship request data for seeding. - * Backed directly by the SponsorshipRequest domain entity. - */ -export type DemoSponsorshipRequestDTO = SponsorshipRequest; - -/** - * Demo sponsorship pricing configuration for entities, using the - * SponsorshipPricing value object to keep pricing logic in the domain. - */ -export interface DemoSponsorshipPricingDTO { - entityType: SponsorableEntityType; - entityId: string; - pricing: SponsorshipPricing; -} - -/** - * Demo sponsors data - realistic sim racing sponsors. - */ -export const DEMO_SPONSORS: DemoSponsorDTO[] = [ - { - id: 'sponsor-fanatec', - name: 'Fanatec', - contactEmail: 'partnerships@fanatec.com', - logoUrl: '/images/sponsors/fanatec.svg', - websiteUrl: 'https://fanatec.com', - tagline: "The world's leading sim racing hardware", - }, - { - id: 'sponsor-simucube', - name: 'Simucube', - contactEmail: 'sponsors@simucube.com', - logoUrl: '/images/sponsors/simucube.svg', - websiteUrl: 'https://simucube.com', - tagline: 'Professional Direct Drive Wheels', - }, - { - id: 'sponsor-heusinkveld', - name: 'Heusinkveld', - contactEmail: 'info@heusinkveld.com', - logoUrl: '/images/sponsors/heusinkveld.svg', - websiteUrl: 'https://heusinkveld.com', - tagline: 'Sim Racing Pedals & Hardware', - }, - { - id: 'sponsor-trak-racer', - name: 'Trak Racer', - contactEmail: 'partnerships@trakracer.com', - logoUrl: '/images/sponsors/trak-racer.svg', - websiteUrl: 'https://trakracer.com', - tagline: 'Premium Racing Simulators & Cockpits', - }, - { - id: 'sponsor-simlab', - name: 'Sim-Lab', - contactEmail: 'sponsor@sim-lab.eu', - logoUrl: '/images/sponsors/simlab.svg', - websiteUrl: 'https://sim-lab.eu', - tagline: 'Aluminum Profile Sim Racing Rigs', - }, - { - id: 'sponsor-motionrig', - name: 'MotionRig Pro', - contactEmail: 'business@motionrigpro.com', - logoUrl: '/images/sponsors/motionrig.svg', - websiteUrl: 'https://motionrigpro.com', - tagline: 'Feel every turn, every bump', - }, -]; - -/** - * Create season sponsorships linking sponsors to leagues. - */ -export function createSeasonSponsorships( - leagues: League[], - sponsors: DemoSponsorDTO[], -): DemoSeasonSponsorshipDTO[] { - const sponsorships: DemoSeasonSponsorshipDTO[] = []; - - const FANATEC_ID = sponsors.find((s) => s.id === 'sponsor-fanatec')?.id ?? 'sponsor-fanatec'; - const HEUSINKVELD_ID = - sponsors.find((s) => s.id === 'sponsor-heusinkveld')?.id ?? 'sponsor-heusinkveld'; - const SIMUCUBE_ID = sponsors.find((s) => s.id === 'sponsor-simucube')?.id ?? 'sponsor-simucube'; - const TRAK_RACER_ID = - sponsors.find((s) => s.id === 'sponsor-trak-racer')?.id ?? 'sponsor-trak-racer'; - const SIMLAB_ID = sponsors.find((s) => s.id === 'sponsor-simlab')?.id ?? 'sponsor-simlab'; - const MOTIONRIG_ID = - sponsors.find((s) => s.id === 'sponsor-motionrig')?.id ?? 'sponsor-motionrig'; - - // GridPilot Sprint Series - sponsored by Fanatec (main) + Heusinkveld & Simucube (secondary) - const sprintLeague = leagues.find((l) => l.name === 'GridPilot Sprint Series'); - if (sprintLeague) { - sponsorships.push({ - id: `sponsorship-${sprintLeague.id}-fanatec`, - seasonId: `season-${sprintLeague.id}-demo`, - sponsorId: FANATEC_ID, - tier: 'main', - pricingAmount: 5000, - pricingCurrency: 'USD', - status: 'active', - description: 'Main sponsor for the Sprint Series - premium wheel branding', - }); - sponsorships.push({ - id: `sponsorship-${sprintLeague.id}-heusinkveld`, - seasonId: `season-${sprintLeague.id}-demo`, - sponsorId: HEUSINKVELD_ID, - tier: 'secondary', - pricingAmount: 2000, - pricingCurrency: 'USD', - status: 'active', - }); - sponsorships.push({ - id: `sponsorship-${sprintLeague.id}-simucube`, - seasonId: `season-${sprintLeague.id}-demo`, - sponsorId: SIMUCUBE_ID, - tier: 'secondary', - pricingAmount: 2000, - pricingCurrency: 'USD', - status: 'active', - }); - } - - // GridPilot Endurance Cup - sponsored by Trak Racer (main) + Sim-Lab (secondary) - const enduranceLeague = leagues.find((l) => l.name === 'GridPilot Endurance Cup'); - if (enduranceLeague) { - sponsorships.push({ - id: `sponsorship-${enduranceLeague.id}-trakracer`, - seasonId: `season-${enduranceLeague.id}-demo`, - sponsorId: TRAK_RACER_ID, - tier: 'main', - pricingAmount: 7500, - pricingCurrency: 'USD', - status: 'active', - description: 'Endurance series naming rights', - }); - sponsorships.push({ - id: `sponsorship-${enduranceLeague.id}-simlab`, - seasonId: `season-${enduranceLeague.id}-demo`, - sponsorId: SIMLAB_ID, - tier: 'secondary', - pricingAmount: 3000, - pricingCurrency: 'USD', - status: 'active', - }); - } - - // GridPilot Club Ladder - sponsored by MotionRig Pro (main) - const clubLeague = leagues.find((l) => l.name === 'GridPilot Club Ladder'); - if (clubLeague) { - sponsorships.push({ - id: `sponsorship-${clubLeague.id}-motionrig`, - seasonId: `season-${clubLeague.id}-demo`, - sponsorId: MOTIONRIG_ID, - tier: 'main', - pricingAmount: 3500, - pricingCurrency: 'USD', - status: 'active', - description: 'Club ladder motion platform showcase', - }); - } - - return sponsorships; -} - -/** - * Create sponsorship pricing configurations for demo entities. - * Uses the SponsorshipPricing value object to ensure domain consistency - * and to provide a mix of season, team, driver and race offerings. - */ -export function createSponsorshipPricings( - leagues: League[], - teams: DemoTeamDTO[], - drivers: Driver[], - races: Race[], -): DemoSponsorshipPricingDTO[] { - const pricings: DemoSponsorshipPricingDTO[] = []; - - // League/Season pricing - all leagues can accept sponsorships, with varied configs - leagues.forEach((league, index) => { - let pricing = SponsorshipPricing.defaultLeague(); - - // Vary league pricing/availability for demo richness - if (index % 3 === 1) { - // Some leagues closed for applications - pricing = pricing.setAcceptingApplications(false); - } else if (index % 3 === 2) { - // Some leagues with main-only sponsorship - pricing = pricing.updateSecondarySlot({ available: false, maxSlots: 0 }); - } else { - // Slightly higher price for featured leagues - pricing = pricing.updateMainSlot({ - price: Money.create(1000 + index * 50, 'USD'), - }); - } - - pricings.push({ - entityType: 'season', - entityId: `season-${league.id}-demo`, - pricing, - }); - }); - - // Team pricing - first 10 teams accept sponsorships using team defaults, - // with some teams pausing applications. - teams.slice(0, 10).forEach((team, index) => { - let pricing = SponsorshipPricing.defaultTeam(); - - if (index % 4 === 1) { - // Teams with main + secondary but not currently accepting - pricing = pricing.setAcceptingApplications(false); - } else if (index % 4 === 2) { - // Teams with only secondary slots - pricing = pricing.updateMainSlot({ available: false, maxSlots: 0 }); - } else if (index % 4 === 3) { - // Teams with premium main slot pricing - pricing = pricing.updateMainSlot({ - price: Money.create(750 + index * 25, 'USD'), - }); - } - - pricings.push({ - entityType: 'team', - entityId: team.id, - pricing, - }); - }); - - // Driver pricing - first 20 drivers accept sponsorships with varied availability. - drivers.slice(0, 20).forEach((driver, index) => { - let pricing = SponsorshipPricing.defaultDriver(); - - if (index % 3 === 0) { - // Higher profile drivers - pricing = pricing.updateMainSlot({ - price: Money.create(250 + index * 10, 'USD'), - }); - } else if (index % 3 === 1) { - // Drivers temporarily not accepting sponsorships - pricing = pricing.setAcceptingApplications(false); - } - - pricings.push({ - entityType: 'driver', - entityId: driver.id, - pricing, - }); - }); - - // Race pricing - upcoming races can have title sponsors with different tiers - const upcomingRaces = races.filter((r) => r.status === 'scheduled').slice(0, 10); - upcomingRaces.forEach((race, index) => { - let pricing = SponsorshipPricing.defaultRace(); - - if (index % 2 === 0) { - // Premium events with higher pricing - pricing = pricing.updateMainSlot({ - price: Money.create(350 + index * 30, 'USD'), - }); - } - - pricings.push({ - entityType: 'race', - entityId: race.id, - pricing, - }); - }); - - return pricings; -} - -/** - * Create demo sponsorship requests (some pending, some accepted/rejected). - * Uses the SponsorshipRequest domain entity and Money value object so that - * all downstream sponsor flows can rely on domain behavior. - */ -export function createSponsorshipRequests( - sponsors: DemoSponsorDTO[], - leagues: League[], - teams: DemoTeamDTO[], - drivers: Driver[], - races: Race[], -): DemoSponsorshipRequestDTO[] { - const requests: DemoSponsorshipRequestDTO[] = []; - const now = new Date(); - - const SIMUCUBE_ID = sponsors.find((s) => s.id === 'sponsor-simucube')?.id ?? 'sponsor-simucube'; - const HEUSINKVELD_ID = - sponsors.find((s) => s.id === 'sponsor-heusinkveld')?.id ?? 'sponsor-heusinkveld'; - const TRAK_RACER_ID = - sponsors.find((s) => s.id === 'sponsor-trak-racer')?.id ?? 'sponsor-trak-racer'; - const MOTIONRIG_ID = - sponsors.find((s) => s.id === 'sponsor-motionrig')?.id ?? 'sponsor-motionrig'; - const SIMLAB_ID = sponsors.find((s) => s.id === 'sponsor-simlab')?.id ?? 'sponsor-simlab'; - - // Pending request: Simucube wants to sponsor a driver - if (drivers.length > 6) { - const targetDriver = drivers[5]; - if (targetDriver) { - requests.push( - SponsorshipRequest.create({ - id: 'req-simucube-driver-1', - sponsorId: SIMUCUBE_ID, - entityType: 'driver', - entityId: targetDriver.id, - tier: 'main', - offeredAmount: Money.create(250, 'USD'), - message: - 'We would love to sponsor your racing career! Simucube offers the best direct drive wheels in sim racing.', - createdAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago - }), - ); - } - } - - // Pending request: Heusinkveld wants to sponsor a team - if (teams.length > 3) { - const targetTeam = teams[2]; - if (targetTeam) { - requests.push( - SponsorshipRequest.create({ - id: 'req-heusinkveld-team-1', - sponsorId: HEUSINKVELD_ID, - entityType: 'team', - entityId: targetTeam.id, - tier: 'main', - offeredAmount: Money.create(550, 'USD'), - message: - 'Heusinkveld pedals are known for their precision. We believe your team embodies the same values.', - createdAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), // 3 days ago - }), - ); - } - } - - // Pending request: Trak Racer wants to sponsor a race - const upcomingRace = races.find((r) => r.status === 'scheduled'); - if (upcomingRace) { - requests.push( - SponsorshipRequest.create({ - id: 'req-trakracer-race-1', - sponsorId: TRAK_RACER_ID, - entityType: 'race', - entityId: upcomingRace.id, - tier: 'main', - offeredAmount: Money.create(350, 'USD'), - message: 'We would like to be the title sponsor for this exciting race event!', - createdAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000), // 1 day ago - }), - ); - } - - // Pending request: MotionRig Pro wants secondary spot on a league season - const clubLeague = leagues.find((l) => l.name === 'Sprint Challenge League'); - if (clubLeague) { - requests.push( - SponsorshipRequest.create({ - id: 'req-motionrig-league-1', - sponsorId: MOTIONRIG_ID, - entityType: 'season', - entityId: `season-${clubLeague.id}-demo`, - tier: 'secondary', - offeredAmount: Money.create(1500, 'USD'), - message: - 'MotionRig Pro would love to be a secondary sponsor. Our motion platforms are perfect for your competitive drivers.', - createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago - }), - ); - } - - // Already accepted request (for history) - if (teams.length > 0) { - const acceptedTeam = teams[0]; - if (acceptedTeam) { - requests.push( - SponsorshipRequest.create({ - id: 'req-simlab-team-accepted', - sponsorId: SIMLAB_ID, - entityType: 'team', - entityId: acceptedTeam.id, - tier: 'secondary', - offeredAmount: Money.create(300, 'USD'), - message: 'Sim-Lab rigs are the foundation of any competitive setup.', - status: 'accepted', - createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), // 30 days ago - }), - ); - } - } - - // Already rejected request (for history) - if (drivers.length > 10) { - const rejectedDriver = drivers[10]; - if (rejectedDriver) { - requests.push( - SponsorshipRequest.create({ - id: 'req-motionrig-driver-rejected', - sponsorId: MOTIONRIG_ID, - entityType: 'driver', - entityId: rejectedDriver.id, - tier: 'main', - offeredAmount: Money.create(150, 'USD'), - message: 'Would you like to represent MotionRig Pro?', - status: 'rejected', - createdAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), // 20 days ago - }), - ); - } - } - - return requests; -} \ No newline at end of file diff --git a/testing/fixtures/racing/RacingStaticSeed.ts b/testing/fixtures/racing/RacingStaticSeed.ts deleted file mode 100644 index 815adfeff..000000000 --- a/testing/fixtures/racing/RacingStaticSeed.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Driver } from '@core/racing/domain/entities/Driver'; -import { League } from '@core/racing/domain/entities/League'; -import { Race } from '@core/racing/domain/entities/Race'; -import { Result } from '@core/racing/domain/entities/Result'; -import { Standing } from '@core/racing/domain/entities/Standing'; - -import type { FeedItem } from '@core/social/domain/types/FeedItem'; -import type { SocialFriendSummary } from '@core/social/application/types/SocialUser'; - -import { faker } from '../../helpers/faker/faker'; - -import { - createDrivers, - createLeagues, - createTeams, - createMemberships, - createRaces, - createResults, - createStandings, - createFriendships, - type RacingMembership, - type Friendship, - type DemoTeamDTO, -} from './RacingSeedCore'; -import { - DEMO_SPONSORS, - createSeasonSponsorships, - createSponsorshipPricings, - createSponsorshipRequests, - type DemoSponsorDTO, - type DemoSeasonSponsorshipDTO, - type DemoSponsorshipRequestDTO, - type DemoSponsorshipPricingDTO, -} from './RacingSponsorshipSeed'; -import { - createFeedEvents, - buildFriends, - buildTopLeagues, - buildUpcomingRaces, - buildLatestResults, - type RaceWithResultsDTO, -} from './RacingFeedSeed'; - -/** - * Aggregated racing seed data used by the website DI container - * and other demo infrastructure. - */ -export type RacingSeedData = { - drivers: Driver[]; - leagues: League[]; - races: Race[]; - results: Result[]; - standings: Standing[]; - memberships: RacingMembership[]; - friendships: Friendship[]; - feedEvents: FeedItem[]; - teams: DemoTeamDTO[]; - sponsors: DemoSponsorDTO[]; - seasonSponsorships: DemoSeasonSponsorshipDTO[]; - sponsorshipRequests: DemoSponsorshipRequestDTO[]; - sponsorshipPricings: DemoSponsorshipPricingDTO[]; -}; - -/** - * Create the full static racing seed from the smaller core/sponsorship/feed modules. - */ -export function createStaticRacingSeed(seed: number): RacingSeedData { - faker.seed(seed); - - const drivers = createDrivers(96); - const leagues = createLeagues(drivers.slice(0, 12).map((d) => d.id)); - const teams = createTeams(leagues); - const memberships = createMemberships(drivers, leagues, teams); - const races = createRaces(leagues); - const results = createResults(drivers, races); - const friendships = createFriendships(drivers); - const feedEvents = createFeedEvents(drivers, leagues, races, friendships); - const standings = createStandings(leagues, results); - const sponsors = DEMO_SPONSORS; - const seasonSponsorships = createSeasonSponsorships(leagues, sponsors); - const sponsorshipPricings = createSponsorshipPricings(leagues, teams, drivers, races); - const sponsorshipRequests = createSponsorshipRequests(sponsors, leagues, teams, drivers, races); - - return { - drivers, - leagues, - races, - results, - standings, - memberships, - friendships, - feedEvents, - teams, - sponsors, - seasonSponsorships, - sponsorshipRequests, - sponsorshipPricings, - }; -} - -/** - * Singleton seed used by website demo helpers. - * - * Alpha demo dataset (deterministic, in-memory only): - * - 90+ drivers across multiple leagues - * - Leagues with precomputed races, results and standings - * - Team memberships and friendships forming social “circles” - * - Feed events referencing real driver, league, race and team IDs - */ -const staticSeed = createStaticRacingSeed(42); - -export const drivers = staticSeed.drivers; -export const leagues = staticSeed.leagues; -export const races = staticSeed.races; -export const results = staticSeed.results; -export const standings = staticSeed.standings; -export const teams = staticSeed.teams; -export const memberships = staticSeed.memberships; -export const friendships = staticSeed.friendships; -export const feedEvents = staticSeed.feedEvents; -export const sponsors = staticSeed.sponsors; -export const seasonSponsorships = staticSeed.seasonSponsorships; -export const sponsorshipRequests = staticSeed.sponsorshipRequests; -export const sponsorshipPricings = staticSeed.sponsorshipPricings; - -/** - * Derived friend DTOs for UI consumption. - * This preserves the previous demo-data `friends` shape. - */ -export const friends: SocialFriendSummary[] = buildFriends(staticSeed.drivers, staticSeed.memberships); - -/** - * Top leagues with banner URLs for UI. - */ -export const topLeagues = buildTopLeagues(leagues); - -/** - * Re-export RaceWithResultsDTO and helpers for latest/upcoming races. - */ -export type { RaceWithResultsDTO } from './RacingFeedSeed'; - -export function getUpcomingRaces(limit?: number): readonly Race[] { - return buildUpcomingRaces(races, limit); -} - -export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[] { - return buildLatestResults(races, results, drivers, limit); -} - -/** - * Demo league archetype helper for seeding structure and scoring. - * Kept here as the small, focused definition used by DI. - */ -export type DemoLeagueArchetype = - | { - id: 'sprint-series'; - name: 'GridPilot Sprint Series'; - structure: { mode: 'solo'; maxDrivers: 24 }; - scoringPresetId: 'sprint-main-driver'; - } - | { - id: 'endurance-cup'; - name: 'GridPilot Endurance Cup'; - structure: { mode: 'fixedTeams'; maxTeams: 12; driversPerTeam: 2 }; - scoringPresetId: 'endurance-main-double'; - } - | { - id: 'club-ladder'; - name: 'GridPilot Club Ladder'; - structure: { mode: 'solo'; maxDrivers: 40 }; - scoringPresetId: 'club-default'; - }; - -export function getDemoLeagueArchetypeByName( - leagueName: string, -): DemoLeagueArchetype | undefined { - switch (leagueName) { - case 'GridPilot Sprint Series': - return { - id: 'sprint-series', - name: 'GridPilot Sprint Series', - structure: { mode: 'solo', maxDrivers: 24 }, - scoringPresetId: 'sprint-main-driver', - }; - case 'GridPilot Endurance Cup': - return { - id: 'endurance-cup', - name: 'GridPilot Endurance Cup', - structure: { mode: 'fixedTeams', maxTeams: 12, driversPerTeam: 2 }, - scoringPresetId: 'endurance-main-double', - }; - case 'GridPilot Club Ladder': - return { - id: 'club-ladder', - name: 'GridPilot Club Ladder', - structure: { mode: 'solo', maxDrivers: 40 }, - scoringPresetId: 'club-default', - }; - default: - return undefined; - } -} \ No newline at end of file diff --git a/testing/helpers/faker/faker.ts b/testing/helpers/faker/faker.ts deleted file mode 100644 index a8ec015e3..000000000 --- a/testing/helpers/faker/faker.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { faker as baseFaker } from '@faker-js/faker'; - -const faker = baseFaker; - -// Fixed seed so demo data is stable across builds -faker.seed(20240317); - -export { faker }; \ No newline at end of file diff --git a/testing/mock-api-server.cjs b/testing/mock-api-server.cjs deleted file mode 100644 index b9bbbab3b..000000000 --- a/testing/mock-api-server.cjs +++ /dev/null @@ -1,1192 +0,0 @@ -const http = require('http'); - -const PORT = Number(process.env.PORT || 3000); - -const baseCors = { - 'Access-Control-Allow-Credentials': 'true', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', -}; - -function nowIso() { - return new Date().toISOString(); -} - -function parseCookies(cookieHeader) { - if (!cookieHeader) return {}; - const out = {}; - const parts = String(cookieHeader).split(';'); - for (const part of parts) { - const [rawKey, ...rest] = part.trim().split('='); - if (!rawKey) continue; - const rawValue = rest.join('='); - out[rawKey] = decodeURIComponent(rawValue || ''); - } - return out; -} - -function getDemoMode(req) { - const cookies = parseCookies(req.headers.cookie); - const raw = cookies.gridpilot_demo_mode || 'none'; - if (raw === 'admin' || raw === 'driver' || raw === 'sponsor' || raw === 'none') return raw; - return 'none'; -} - -function getFaultMode(req) { - const cookies = parseCookies(req.headers.cookie); - const raw = cookies.gridpilot_fault_mode || ''; - if (raw === 'null-array' || raw === 'missing-field' || raw === 'invalid-date') return raw; - return null; -} - -function getSessionDriftMode(req) { - const cookies = parseCookies(req.headers.cookie); - const raw = cookies.gridpilot_session_drift || ''; - if (raw === 'invalid-cookie' || raw === 'expired' || raw === 'missing-sponsor-id') return raw; - return null; -} - -function sendJson(res, code, obj) { - res.statusCode = code; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(obj)); -} - -function sendNull(res) { - res.statusCode = 200; - res.setHeader('content-type', 'application/json'); - res.end('null'); -} - -function readRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', (chunk) => { - body += chunk; - }); - req.on('end', () => resolve(body)); - req.on('error', reject); - }); -} - -async function readJsonBody(req) { - const text = await readRequestBody(req); - if (!text) return null; - try { - return JSON.parse(text); - } catch { - return null; - } -} - -function normalizeArrayFields(obj, fields) { - if (!obj || typeof obj !== 'object') return obj; - const out = { ...obj }; - for (const field of fields) { - if (out[field] == null) { - out[field] = []; - continue; - } - if (!Array.isArray(out[field])) { - out[field] = []; - } - } - return out; -} - -const ONE_BY_ONE_PNG_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0pS0kAAAAASUVORK5CYII='; - -function sendPng(res, code = 200) { - res.statusCode = code; - res.setHeader('content-type', 'image/png'); - res.end(Buffer.from(ONE_BY_ONE_PNG_BASE64, 'base64')); -} - -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function matchPathname(pathname, template) { - const re = new RegExp(`^${template.split('/').map(escapeRegExp).join('/')}$`); - return re.test(pathname); -} - -function getPathParam(pathname, matcher) { - const match = pathname.match(matcher); - return match ? match[1] : null; -} - -function getSessionForMode(mode, req) { - if (mode === 'none') return null; - - const cookies = parseCookies(req.headers.cookie); - const sponsorId = cookies.gridpilot_sponsor_id || 'demo-sponsor-1'; - - if (mode === 'admin') { - return { - token: 'test-token-admin', - user: { - userId: 'user-admin', - email: 'admin@gridpilot.test', - displayName: 'Demo Admin', - primaryDriverId: 'driver-admin', - role: 'league-admin', // MATCH WEBSITE EXPECTATIONS - }, - }; - } - - if (mode === 'sponsor') { - return { - token: 'test-token-sponsor', - user: { - userId: 'user-sponsor', - email: 'sponsor@gridpilot.test', - displayName: 'Demo Sponsor User', - primaryDriverId: 'driver-sponsor', - sponsorId, - role: 'sponsor', // MATCH WEBSITE EXPECTATIONS - }, - }; - } - - return { - token: 'test-token-driver', - user: { - userId: 'user-driver', - email: 'driver@gridpilot.test', - displayName: 'Demo Driver', - primaryDriverId: 'driver-1', - role: 'driver', // MATCH WEBSITE EXPECTATIONS - }, - }; -} - -const DEMO = { - leagueId: 'league-1', - teamId: 'team-1', - raceId: 'race-1', - protestId: 'protest-1', - seasonId: 'season-1', - sponsorId: 'demo-sponsor-1', -}; - -function buildLeagueList() { - return { - leagues: [ - { - id: DEMO.leagueId, - name: 'Demo League', - description: 'Demo league for docker smoke tests', - ownerId: 'driver-admin', - createdAt: nowIso(), - usedSlots: 2, - timingSummary: 'Weekly', - settings: { maxDrivers: 50 }, - scoring: { - scoringPresetName: 'Demo rules', - scoringPatternSummary: 'Standard', - }, - }, - ], - totalCount: 1, - }; -} - -function buildTeamsList() { - return { - teams: [ - { - id: DEMO.teamId, - name: 'Demo Team', - tag: 'DEMO', - description: 'Demo team for docker smoke tests', - ownerId: 'driver-admin', - createdAt: nowIso(), - memberCount: 2, - leagues: [DEMO.leagueId], - isRecruiting: true, - totalWins: 5, - totalRaces: 20, - rating: 2500, - logoUrl: `/media/teams/${DEMO.teamId}/logo`, - }, - ], - totalCount: 1, - }; -} - -function buildRaceSchedule(seasonId) { - const date = nowIso(); - return { - seasonId, - published: true, - races: [ - { - id: DEMO.raceId, - name: 'Demo Race', - date, - scheduledAt: date, - track: 'Demo Track', - car: 'Demo Car', - sessionType: 'race', - status: 'scheduled', - isRegistered: false, - }, - ], - }; -} - -function buildStandings() { - return { - standings: [ - { driverId: 'driver-1', points: 100, position: 1 }, - { driverId: 'driver-admin', points: 90, position: 2 }, - ], - }; -} - -function buildMemberships() { - return { - members: [ - { driverId: 'driver-admin', role: 'owner', joinedAt: nowIso() }, - { driverId: 'driver-1', role: 'member', joinedAt: nowIso() }, - { driverId: 'driver-sponsor', role: 'member', joinedAt: nowIso() }, - ], - }; -} - -function buildDriver(driverId) { - return { - currentDriver: { - id: driverId, - name: driverId === 'driver-admin' ? 'Demo Admin Driver' : 'Demo Driver', - country: 'DE', - createdAt: nowIso(), - }, - }; -} - -function buildDriverProfile(driverId) { - return { - currentDriver: { - id: driverId, - name: driverId === 'driver-admin' ? 'Demo Admin Driver' : 'Demo Driver', - country: 'DE', - avatarUrl: '/images/avatars/neutral-default-avatar.jpeg', - iracingId: driverId === 'driver-admin' ? '1002' : '1001', - joinedAt: nowIso(), - rating: 2500, - globalRank: 42, - consistency: 78, - bio: '', - totalDrivers: 1000, - }, - stats: { - totalRaces: 12, - wins: 2, - podiums: 5, - dnfs: 1, - avgFinish: 6.3, - bestFinish: 1, - worstFinish: 18, - finishRate: 91.7, - winRate: 16.7, - podiumRate: 41.7, - percentile: 42, - rating: 2500, - consistency: 78, - overallRank: 42, - }, - finishDistribution: { - totalRaces: 12, - wins: 2, - podiums: 5, - topTen: 8, - dnfs: 1, - other: 3, - }, - teamMemberships: [], - socialSummary: { - friendsCount: 1, - friends: [ - { - id: 'driver-admin', - name: 'Demo Admin Driver', - country: 'DE', - avatarUrl: '/images/avatars/male-default-avatar.jpg', - }, - ], - }, - extendedProfile: { - socialHandles: [], - achievements: [], - racingStyle: 'Balanced', - favoriteTrack: 'Spa', - favoriteCar: 'Porsche 992 Cup', - timezone: 'Europe/Berlin', - availableHours: 'Evenings', - lookingForTeam: false, - openToRequests: true, - }, - }; -} - -function buildTeamDetails(teamId) { - return { - team: { - id: teamId, - name: 'Demo Team', - ownerId: 'driver-admin', - createdAt: nowIso(), - description: '', - }, - }; -} - -function buildTeamMembers(teamId) { - return { - teamId, - members: [ - { driverId: 'driver-admin', role: 'owner', joinedAt: nowIso(), driver: { id: 'driver-admin', name: 'Demo Admin Driver' } }, - { driverId: 'driver-1', role: 'member', joinedAt: nowIso(), driver: { id: 'driver-1', name: 'Demo Driver' } }, - ], - }; -} - -function buildRacePageData() { - const date = nowIso(); - return { - races: [ - { - id: DEMO.raceId, - name: 'Demo Race', - date, - scheduledAt: date, - leagueId: DEMO.leagueId, - leagueName: 'Demo League', - track: 'Demo Track', - car: 'Demo Car', - status: 'scheduled', - strengthOfField: null, - }, - ], - }; -} - -function buildRaceDetail(raceId) { - const date = nowIso(); - return { - race: { - id: raceId, - name: 'Demo Race', - date, - track: 'Demo Track', - car: 'Demo Car', - status: 'scheduled', - leagueId: DEMO.leagueId, - }, - league: { id: DEMO.leagueId, name: 'Demo League' }, - entryList: [], - registration: { isRegistered: false }, - userResult: null, - }; -} - -function buildRaceResults(raceId) { - return { - raceId, - results: [], - }; -} - -function buildSponsorDashboard(sponsorId) { - return { - sponsorId, - sponsor: { id: sponsorId, name: 'Demo Sponsor', logoUrl: '', websiteUrl: '' }, - stats: { impressions: 0, clicks: 0 }, - activeSponsorships: [], - recentCampaigns: [], - }; -} - -function buildSponsorSponsorships(sponsorId) { - return { - sponsorId, - sponsorships: [], - }; -} - -function buildSponsorSettings(sponsorId) { - return { - profile: { sponsorId, name: 'Demo Sponsor', websiteUrl: '', logoUrl: '' }, - notifications: {}, - privacy: {}, - }; -} - -function buildPendingSponsorshipRequests() { - return { - requests: [], - }; -} - -function buildDashboardOverview() { - const scheduledAt = nowIso(); - - return { - currentDriver: { - id: 'driver-1', - name: 'Demo Driver', - country: 'DE', - avatarUrl: '/images/avatars/neutral-default-avatar.jpeg', - rating: 2500, - globalRank: 42, - totalRaces: 12, - wins: 2, - podiums: 5, - consistency: 78, - }, - myUpcomingRaces: [ - { - id: DEMO.raceId, - leagueId: DEMO.leagueId, - leagueName: 'Demo League', - track: 'Spa', - car: 'Porsche 992 Cup', - scheduledAt, - status: 'scheduled', - isMyLeague: true, - }, - ], - otherUpcomingRaces: [], - upcomingRaces: [ - { - id: DEMO.raceId, - leagueId: DEMO.leagueId, - leagueName: 'Demo League', - track: 'Spa', - car: 'Porsche 992 Cup', - scheduledAt, - status: 'scheduled', - isMyLeague: true, - }, - ], - activeLeaguesCount: 1, - nextRace: { - id: DEMO.raceId, - leagueId: DEMO.leagueId, - leagueName: 'Demo League', - track: 'Spa', - car: 'Porsche 992 Cup', - scheduledAt, - status: 'scheduled', - isMyLeague: true, - }, - recentResults: [], - leagueStandingsSummaries: [ - { leagueId: DEMO.leagueId, leagueName: 'Demo League', position: 1, totalDrivers: 10, points: 100 }, - ], - feedSummary: { - items: [ - { - id: 'feed-1', - type: 'info', - headline: 'Welcome to GridPilot', - body: 'Demo data from the docker test API.', - timestamp: nowIso(), - ctaLabel: 'Browse leagues', - ctaHref: '/leagues', - }, - ], - }, - friends: [ - { - id: 'driver-admin', - name: 'Demo Admin Driver', - avatarUrl: '/images/avatars/male-default-avatar.jpg', - country: 'DE', - }, - ], - }; -} - -const server = http.createServer((req, res) => { - const origin = req.headers.origin || 'http://localhost:3100'; - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Vary', 'Origin'); - for (const [k, v] of Object.entries(baseCors)) res.setHeader(k, v); - - if (req.method === 'OPTIONS') { - res.statusCode = 204; - return res.end(); - } - - const url = new URL(req.url, 'http://localhost'); - const { pathname: rawPathname, searchParams } = url; - - // Normalize trailing slashes so `/foo` and `/foo/` behave the same in mocks. - // This prevents false-negative 404s caused by minor URL formatting differences. - const pathname = rawPathname !== '/' ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname; - - const demoMode = getDemoMode(req); - const faultMode = getFaultMode(req); - const sessionDriftMode = getSessionDriftMode(req); - - const send = (code, obj) => sendJson(res, code, obj); - - if (pathname === '/health') return send(200, { status: 'ok' }); - - if (pathname === '/auth/demo-login' && req.method === 'POST') { - return readJsonBody(req) - .then((body) => { - const role = body && typeof body.role === 'string' ? body.role : 'driver'; - - // Map role to mode for session lookup - // The role parameter from tests should match what website expects - let mode; - if (role === 'sponsor') { - mode = 'sponsor'; - } else if (role === 'league-admin' || role === 'league-owner' || role === 'league-steward' || role === 'super-admin' || role === 'system-owner') { - mode = 'admin'; // All admin-like roles use admin mode - } else { - mode = 'driver'; // Default to driver - } - - const session = getSessionForMode(mode, req); - - // For the docker smoke environment, the website middleware checks gp_session to - // allow protected routes, while the mock session endpoint uses gridpilot_demo_mode. - const gpSessionValue = `demo-${mode}-session`; - - // Set cookies with proper domain for Docker environment - // In Docker tests, both website (3100) and API (3101) are on localhost - // so we need to set cookies for localhost domain - const domain = 'localhost'; - const cookies = [ - `gp_session=${encodeURIComponent(gpSessionValue)}; Path=/; HttpOnly; Domain=${domain}`, - `gridpilot_demo_mode=${encodeURIComponent(mode)}; Path=/; Domain=${domain}`, - ]; - - if (mode === 'sponsor') { - cookies.push(`gridpilot_sponsor_id=${encodeURIComponent(DEMO.sponsorId)}; Path=/; Domain=${domain}`); - cookies.push(`gridpilot_sponsor_name=${encodeURIComponent('Demo Sponsor')}; Path=/; Domain=${domain}`); - } - - res.setHeader('Set-Cookie', cookies); - return send(200, session); - }) - .catch((err) => { - return send(500, { message: String(err?.message || err || 'demo-login failed') }); - }); - } - - if (pathname === '/policy/snapshot') { - return send(200, { - policyVersion: 1, - operationalMode: 'test', - maintenanceAllowlist: { view: [], mutate: [] }, - capabilities: {}, - loadedFrom: 'defaults', - loadedAtIso: nowIso(), - }); - } - - if (pathname === '/auth/session') { - const session = getSessionForMode(demoMode, req); - - // Test-mock behavior: "public" mode returns 200 with a null session so the browser - // does not emit noisy "Failed to load resource 401/403" console errors. - if (!session) return sendNull(res); - - // Drift injection is only enabled when explicitly requested via cookie. - if (sessionDriftMode === 'expired') { - return sendNull(res); - } - - if (sessionDriftMode === 'invalid-cookie') { - return send(200, { token: 'invalid', user: session.user }); - } - - if (sessionDriftMode === 'missing-sponsor-id') { - if (session.user && typeof session.user === 'object' && 'sponsorId' in session.user) { - const { sponsorId: _omit, ...restUser } = session.user; - return send(200, { token: session.token, user: restUser }); - } - return send(200, session); - } - - return send(200, session); - } - - const avatarPath = getPathParam(pathname, /^\/media\/avatar\/([^/]+)$/); - if (avatarPath) return sendPng(res, 200); - - const leagueMedia = pathname.match(/^\/media\/leagues\/([^/]+)\/(cover|logo)$/); - if (leagueMedia) return sendPng(res, 200); - - const teamMedia = pathname.match(/^\/media\/teams\/([^/]+)\/logo$/); - if (teamMedia) return sendPng(res, 200); - - const sponsorMedia = pathname.match(/^\/media\/sponsors\/([^/]+)\/logo$/); - if (sponsorMedia) return sendPng(res, 200); - - if (pathname === '/leagues/all-with-capacity') { - const payload = normalizeArrayFields(buildLeagueList(), ['leagues']); - if (faultMode === 'null-array') payload.leagues = null; - return send(200, payload); - } - if (pathname === '/leagues/all-with-capacity-and-scoring') { - const payload = normalizeArrayFields(buildLeagueList(), ['leagues']); - if (faultMode === 'null-array') payload.leagues = null; - return send(200, payload); - } - if (pathname === '/teams/all') { - const payload = normalizeArrayFields(buildTeamsList(), ['teams']); - if (faultMode === 'null-array') payload.teams = null; - return send(200, payload); - } - - if (pathname === '/leagues/scoring-presets') { - return send(200, { - presets: [ - { - id: 'preset-1', - name: 'Demo Scoring', - description: 'Demo scoring preset for docker smoke tests', - primaryChampionshipType: 'driver', - sessionSummary: 'Main race', - bonusSummary: '', - dropPolicySummary: 'All results count', - defaultTimings: { - practiceMinutes: 15, - qualifyingMinutes: 10, - sprintRaceMinutes: 0, - mainRaceMinutes: 30, - sessionCount: 1, - }, - }, - ], - }); - } - - if (pathname === '/dashboard/overview') { - const payload = buildDashboardOverview(); - - if (faultMode === 'null-array') { - if (payload.feedSummary && payload.feedSummary.items) payload.feedSummary.items = null; - if (payload.friends) payload.friends = null; - if (payload.leagueStandingsSummaries) payload.leagueStandingsSummaries = null; - if (payload.myUpcomingRaces) payload.myUpcomingRaces = null; - if (payload.otherUpcomingRaces) payload.otherUpcomingRaces = null; - if (payload.upcomingRaces) payload.upcomingRaces = null; - } - - if (faultMode === 'invalid-date') { - if (payload.nextRace && payload.nextRace.scheduledAt) payload.nextRace.scheduledAt = 'not-a-date'; - if (Array.isArray(payload.upcomingRaces) && payload.upcomingRaces[0]?.scheduledAt) payload.upcomingRaces[0].scheduledAt = 'not-a-date'; - if (Array.isArray(payload.myUpcomingRaces) && payload.myUpcomingRaces[0]?.scheduledAt) payload.myUpcomingRaces[0].scheduledAt = 'not-a-date'; - } - - return send(200, payload); - } - - // Admin dashboard stats endpoint - if (pathname === '/admin/dashboard/stats') { - // Check authorization - only admin roles can access - if (demoMode !== 'admin') { - return send(403, { message: 'Forbidden' }); - } - return send(200, { - totalLeagues: 1, - totalMembers: 10, - totalRevenue: 5000, - activeSponsorships: 2, - }); - } - - if (pathname === '/drivers/leaderboard') return send(200, { drivers: [] }); - if (pathname === '/drivers/current') - return send(200, buildDriver(getSessionForMode(demoMode, req)?.user?.primaryDriverId || 'driver-1')); - - if (pathname === '/races/page-data') { - const payload = normalizeArrayFields(buildRacePageData(), ['races']); - if (faultMode === 'null-array') payload.races = null; - if (faultMode === 'invalid-date' && Array.isArray(payload.races) && payload.races[0]) { - payload.races[0].date = 'not-a-date'; - payload.races[0].scheduledAt = 'not-a-date'; - } - return send(200, payload); - } - - if (pathname === '/races/reference/penalty-types') { - return send(200, { - penaltyTypes: [ - { type: 'time_penalty', requiresValue: true, valueKind: 'seconds' }, - { type: 'grid_penalty', requiresValue: true, valueKind: 'grid_positions' }, - { type: 'points_deduction', requiresValue: true, valueKind: 'points' }, - { type: 'disqualification', requiresValue: false, valueKind: 'none' }, - { type: 'warning', requiresValue: false, valueKind: 'none' }, - { type: 'license_points', requiresValue: true, valueKind: 'points' }, - ], - defaultReasons: { - upheld: 'Protest upheld based on steward review.', - dismissed: 'Protest dismissed due to insufficient evidence.', - }, - }); - } - - const leagueProtestsMatch = pathname.match(/^\/leagues\/([^/]+)\/protests(?:\/([^/]+))?$/); - if (leagueProtestsMatch) { - const leagueId = leagueProtestsMatch[1]; - const protestId = leagueProtestsMatch[2] || DEMO.protestId; - - return send(200, { - protests: [ - { - id: protestId, - leagueId, - raceId: DEMO.raceId, - protestingDriverId: 'driver-1', - accusedDriverId: 'driver-admin', - submittedAt: nowIso(), - description: 'Demo protest for docker smoke tests', - status: 'pending', - }, - ], - racesById: { - [DEMO.raceId]: { - id: DEMO.raceId, - name: 'Demo Race', - date: nowIso(), - leagueName: 'Demo League', - }, - }, - driversById: { - 'driver-1': { - id: 'driver-1', - iracingId: '1001', - name: 'Demo Driver', - country: 'DE', - joinedAt: nowIso(), - }, - 'driver-admin': { - id: 'driver-admin', - iracingId: '1002', - name: 'Demo Admin Driver', - country: 'DE', - joinedAt: nowIso(), - }, - }, - }); - } - - const raceIdProtests = getPathParam(pathname, /^\/races\/([^/]+)\/protests$/); - if (raceIdProtests) { - return send(200, { - protests: [ - { - id: DEMO.protestId, - protestingDriverId: 'driver-1', - accusedDriverId: 'driver-admin', - incident: { type: 'contact' }, - lap: 1, - description: 'Demo incident', - status: 'pending', - filedAt: nowIso(), - }, - ], - driverMap: { - 'driver-1': 'Demo Driver', - 'driver-admin': 'Demo Admin Driver', - }, - }); - } - - const raceIdPenalties = getPathParam(pathname, /^\/races\/([^/]+)\/penalties$/); - if (raceIdPenalties) { - return send(200, { - penalties: [], - driverMap: {}, - }); - } - - const leagueIdFromMemberships = getPathParam(pathname, /^\/leagues\/([^/]+)\/memberships$/); - if (leagueIdFromMemberships) { - if (leagueIdFromMemberships !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - const payload = normalizeArrayFields(buildMemberships(), ['members']); - if (faultMode === 'null-array') payload.members = null; - return send(200, payload); - } - - const leagueIdFromStandings = getPathParam(pathname, /^\/leagues\/([^/]+)\/standings$/); - if (leagueIdFromStandings) { - if (leagueIdFromStandings !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - const payload = normalizeArrayFields(buildStandings(), ['standings']); - if (faultMode === 'null-array') payload.standings = null; - return send(200, payload); - } - - const leagueIdFromSchedule = getPathParam(pathname, /^\/leagues\/([^/]+)\/schedule$/); - if (leagueIdFromSchedule) { - if (leagueIdFromSchedule !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - const seasonId = searchParams.get('seasonId') || DEMO.seasonId; - const payload = normalizeArrayFields(buildRaceSchedule(seasonId), ['races']); - - if (faultMode === 'null-array') payload.races = null; - - if (faultMode === 'invalid-date' && Array.isArray(payload.races) && payload.races[0]) { - payload.races[0].date = 'not-a-date'; - payload.races[0].scheduledAt = 'not-a-date'; - } - - if (faultMode === 'missing-field' && Array.isArray(payload.races) && payload.races[0]) { - delete payload.races[0].track; - } - - return send(200, payload); - } - - const leagueIdFromWallet = getPathParam(pathname, /^\/leagues\/([^/]+)\/wallet$/); - if (leagueIdFromWallet) { - if (leagueIdFromWallet !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - const date = nowIso(); - const payload = { - balance: 2880, - currency: 'USD', - totalRevenue: 3200, - totalFees: 320, - totalWithdrawals: 0, - pendingPayouts: 0, - canWithdraw: true, - transactions: [ - { - id: 'wallet-tx-1', - type: 'sponsorship', - description: 'Demo sponsorship revenue', - amount: 1600, - fee: 160, - netAmount: 1440, - date, - status: 'completed', - reference: 'sponsorship-1', - }, - ], - }; - - if (faultMode === 'null-array') payload.transactions = null; - - if (faultMode === 'invalid-date' && Array.isArray(payload.transactions) && payload.transactions[0]) { - payload.transactions[0].date = 'not-a-date'; - } - - return send(200, payload); - } - - const leagueIdFromWalletWithdraw = getPathParam(pathname, /^\/leagues\/([^/]+)\/wallet\/withdraw$/); - if (leagueIdFromWalletWithdraw && req.method === 'POST') { - if (leagueIdFromWalletWithdraw !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - return send(200, { success: true }); - } - - const leagueIdFromRaces = getPathParam(pathname, /^\/leagues\/([^/]+)\/races$/); - if (leagueIdFromRaces) { - if (leagueIdFromRaces !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - return send(200, { races: [buildRaceDetail(DEMO.raceId).race] }); - } - - const leagueIdFromSeasons = getPathParam(pathname, /^\/leagues\/([^/]+)\/seasons$/); - if (leagueIdFromSeasons) { - if (leagueIdFromSeasons !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - return send(200, [ - { seasonId: DEMO.seasonId, name: 'Season 1', status: 'active', startDate: nowIso(), endDate: nowIso() }, - ]); - } - - const leagueIdFromRosterMembers = getPathParam(pathname, /^\/leagues\/([^/]+)\/admin\/roster\/members$/); - if (leagueIdFromRosterMembers) { - // Check authorization - only admin roles can access - if (demoMode !== 'admin') { - return send(403, { message: 'Forbidden' }); - } - return send(200, [ - { - driverId: 'driver-admin', - role: 'owner', - joinedAt: nowIso(), - driver: { id: 'driver-admin', name: 'Demo Admin Driver' }, - }, - { - driverId: 'driver-1', - role: 'member', - joinedAt: nowIso(), - driver: { id: 'driver-1', name: 'Demo Driver' }, - }, - ]); - } - - const leagueIdFromJoinRequests = getPathParam(pathname, /^\/leagues\/([^/]+)\/admin\/roster\/join-requests$/); - if (leagueIdFromJoinRequests) { - // Check authorization - only admin roles can access - if (demoMode !== 'admin') { - return send(403, { message: 'Forbidden' }); - } - return send(200, [ - { - id: 'join-request-1', - leagueId: leagueIdFromJoinRequests, - driverId: 'driver-sponsor', - requestedAt: nowIso(), - message: 'Please approve my join request', - driver: { id: 'driver-sponsor', name: 'Demo Sponsor Driver' }, - }, - ]); - } - - const seasonIdFromSponsorships = getPathParam(pathname, /^\/leagues\/seasons\/([^/]+)\/sponsorships$/); - if (seasonIdFromSponsorships) { - return send(200, { - sponsorships: [ - { id: 'sponsorship-1', seasonId: seasonIdFromSponsorships, sponsorId: DEMO.sponsorId, tier: 'main', status: 'active' }, - ], - }); - } - - const driverId = getPathParam(pathname, /^\/drivers\/([^/]+)$/); - if (driverId) return send(200, buildDriver(driverId)); - - const driverIdProfile = getPathParam(pathname, /^\/drivers\/([^/]+)\/profile$/); - if (driverIdProfile) { - // This endpoint is public, no auth required - return send(200, buildDriverProfile(driverIdProfile)); - } - - const teamIdDetails = getPathParam(pathname, /^\/teams\/([^/]+)$/); - if (teamIdDetails) return send(200, buildTeamDetails(teamIdDetails)); - - const teamIdMembers = getPathParam(pathname, /^\/teams\/([^/]+)\/members$/); - if (teamIdMembers) return send(200, buildTeamMembers(teamIdMembers)); - - const teamIdMembership = getPathParam(pathname, /^\/teams\/([^/]+)\/members\/([^/]+)$/); - if (teamIdMembership) { - const parts = pathname.split('/'); - const teamId = parts[2]; - const memberDriverId = parts[4]; - return send(200, { teamId, driverId: memberDriverId, role: memberDriverId === 'driver-admin' ? 'owner' : 'member' }); - } - - const raceIdDetail = getPathParam(pathname, /^\/races\/([^/]+)$/); - if (raceIdDetail) { - if (raceIdDetail !== DEMO.raceId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); - - const driverIdForRace = - searchParams.get('driverId') || (getSessionForMode(demoMode, req)?.user?.primaryDriverId || 'driver-1'); - void driverIdForRace; - - const payload = buildRaceDetail(raceIdDetail); - - if (faultMode === 'invalid-date' && payload.race) { - payload.race.date = 'not-a-date'; - } - - if (faultMode === 'null-array') { - payload.entryList = null; - } - - return send(200, payload); - } - - const raceIdSof = getPathParam(pathname, /^\/races\/([^/]+)\/sof$/); - if (raceIdSof) return send(200, { raceId: raceIdSof, strengthOfField: 2500 }); - - const raceIdResults = getPathParam(pathname, /^\/races\/([^/]+)\/results$/); - if (raceIdResults) return send(200, buildRaceResults(raceIdResults)); - - const sponsorDashboard = getPathParam(pathname, /^\/sponsors\/dashboard\/([^/]+)$/); - if (sponsorDashboard) { - const payload = buildSponsorDashboard(sponsorDashboard); - - if (faultMode === 'null-array') { - payload.activeSponsorships = null; - payload.recentCampaigns = null; - } - - if (faultMode === 'missing-field' && payload.sponsor) { - delete payload.sponsor.name; - } - - return send(200, payload); - } - - const sponsorSponsorships = getPathParam(pathname, /^\/sponsors\/([^/]+)\/sponsorships$/); - if (sponsorSponsorships) { - const payload = buildSponsorSponsorships(sponsorSponsorships); - if (faultMode === 'null-array') payload.sponsorships = null; - return send(200, payload); - } - - const sponsorGet = getPathParam(pathname, /^\/sponsors\/([^/]+)$/); - if (sponsorGet) return send(200, { sponsor: { id: sponsorGet, name: 'Demo Sponsor', logoUrl: '', websiteUrl: '' } }); - - if (matchPathname(pathname, '/sponsors/pricing')) return send(200, { pricing: [] }); - if (matchPathname(pathname, '/sponsors')) return send(200, { sponsors: [{ id: DEMO.sponsorId, name: 'Demo Sponsor', logoUrl: '', websiteUrl: '' }] }); - - if (pathname === '/sponsors/requests') return send(200, buildPendingSponsorshipRequests()); - - const sponsorBilling = getPathParam(pathname, /^\/sponsors\/billing\/([^/]+)$/); - if (sponsorBilling) { - // Check authorization - only sponsor role can access - if (demoMode !== 'sponsor') { - return send(403, { message: 'Forbidden' }); - } - const today = new Date(); - const invoiceDate = new Date(today.getFullYear(), today.getMonth(), 1).toISOString(); - const dueDate = new Date(today.getFullYear(), today.getMonth(), 15).toISOString(); - const nextPaymentDate = new Date(today.getFullYear(), today.getMonth() + 1, 1).toISOString(); - - return send(200, { - paymentMethods: [ - { - id: 'pm-1', - type: 'card', - last4: '4242', - brand: 'Visa', - isDefault: true, - expiryMonth: 12, - expiryYear: 2030, - }, - ], - invoices: [ - { - id: 'inv-1', - invoiceNumber: 'GP-0001', - date: invoiceDate, - dueDate, - amount: 100, - vatAmount: 20, - totalAmount: 120, - status: 'paid', - description: 'Demo sponsorship invoice', - sponsorshipType: 'league', - pdfUrl: '/billing/invoices/inv-1.pdf', - }, - ], - stats: { - totalSpent: 120, - pendingAmount: 0, - nextPaymentDate, - nextPaymentAmount: 0, - activeSponsorships: 0, - averageMonthlySpend: 20, - }, - }); - } - - const sponsorSettings = getPathParam(pathname, /^\/sponsors\/settings\/([^/]+)$/); - if (sponsorSettings) { - // Check authorization - only sponsor role can access - if (demoMode !== 'sponsor') { - return send(403, { message: 'Forbidden' }); - } - return send(200, buildSponsorSettings(sponsorSettings)); - } - - const sponsorLeagueAvailable = pathname === '/sponsors/leagues/available'; - if (sponsorLeagueAvailable) { - // Check authorization - only sponsor role can access - if (demoMode !== 'sponsor') { - return send(403, { message: 'Forbidden' }); - } - return send(200, [ - { - id: DEMO.leagueId, - name: 'Demo League', - game: 'iRacing', - drivers: 24, - avgViewsPerRace: 3200, - mainSponsorSlot: { available: true, price: 1500 }, - secondarySlots: { available: 2, total: 4, price: 500 }, - rating: 4.6, - tier: 'standard', - nextRace: 'Sunday 19:00', - seasonStatus: 'active', - description: 'Demo league available for sponsorship (docker smoke tests).', - }, - ]); - } - - const sponsorLeagueDetail = getPathParam(pathname, /^\/sponsors\/leagues\/([^/]+)\/detail$/); - if (sponsorLeagueDetail) { - // Check authorization - only sponsor role can access - if (demoMode !== 'sponsor') { - return send(403, { message: 'Forbidden' }); - } - return send(200, { - league: { - id: sponsorLeagueDetail, - name: 'Demo League', - game: 'iRacing', - tier: 'standard', - season: '2025 S1', - description: 'Demo league detail for sponsor pages (docker smoke tests).', - drivers: 24, - races: 10, - completedRaces: 2, - totalImpressions: 42000, - avgViewsPerRace: 3200, - engagement: 78, - rating: 4.6, - seasonStatus: 'active', - seasonDates: { start: nowIso(), end: nowIso() }, - nextRace: { name: 'Demo Race 3', date: nowIso() }, - sponsorSlots: { - main: { - available: true, - price: 1500, - benefits: ['Logo on broadcast overlay', 'Mentioned in race intro'], - }, - secondary: { - available: 2, - total: 4, - price: 500, - benefits: ['Logo on results page', 'Listed on sponsor board'], - }, - }, - }, - drivers: [ - { - id: 'driver-1', - name: 'Demo Driver', - country: 'DE', - position: 1, - races: 2, - impressions: 6400, - team: 'Demo Team', - }, - ], - races: [ - { - id: DEMO.raceId, - name: 'Demo Race', - date: nowIso(), - views: 3200, - status: 'completed', - }, - { - id: 'race-2', - name: 'Demo Race 2', - date: nowIso(), - views: 0, - status: 'upcoming', - }, - ], - }); - } - - return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname }); -}); - -server.listen(PORT, () => { - // eslint-disable-next-line no-console - console.log(`[api-mock] listening on ${PORT}`); -}); \ No newline at end of file diff --git a/testing/types/FriendDTO.ts b/testing/types/FriendDTO.ts deleted file mode 100644 index 1238dc32c..000000000 --- a/testing/types/FriendDTO.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface FriendDTO { - driverId: string; - displayName: string; - avatarUrl: string; - isOnline: boolean; - lastSeen: Date; - primaryLeagueId?: string; - primaryTeamId?: string; -} \ No newline at end of file diff --git a/tests/smoke/website-pages.test.ts b/tests/e2e/website/website-pages.test.ts similarity index 100% rename from tests/smoke/website-pages.test.ts rename to tests/e2e/website/website-pages.test.ts diff --git a/tests/RegistrationAndTeamUseCases.test.ts b/tests/integration/racing/RegistrationAndTeamUseCases.test.ts similarity index 100% rename from tests/RegistrationAndTeamUseCases.test.ts rename to tests/integration/racing/RegistrationAndTeamUseCases.test.ts diff --git a/tests/smoke/websiteAuth.ts b/tests/smoke/websiteAuth.ts deleted file mode 100644 index 6a9f1b19f..000000000 --- a/tests/smoke/websiteAuth.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { Page, BrowserContext } from '@playwright/test'; -import type { RouteAccess } from './websiteRouteInventory'; - -export type WebsiteAuthContext = 'public' | 'auth' | 'admin' | 'sponsor'; - -export type WebsiteSessionDriftMode = 'invalid-cookie' | 'expired' | 'missing-sponsor-id'; -export type WebsiteFaultMode = 'null-array' | 'missing-field' | 'invalid-date'; - -export function authContextForAccess(access: RouteAccess): WebsiteAuthContext { - if (access === 'public') return 'public'; - if (access === 'auth') return 'auth'; - if (access === 'admin') return 'admin'; - return 'sponsor'; -} - -export async function setWebsiteAuthContext( - context: BrowserContext, - auth: WebsiteAuthContext, - options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {}, -): Promise { - const domain = 'localhost'; - const base = { domain, path: '/' }; - - // The website uses `gp_session` cookie for authentication - // For smoke tests, we use normal login API with seeded demo user credentials - // to get real session cookies - - if (auth === 'public') { - // No authentication needed - await context.clearCookies(); - return; - } - - // For authenticated contexts, we need to perform a normal login - // This ensures we get real session cookies with proper structure - // Note: All auth contexts use the same seeded demo driver user for simplicity - // Role-based access control is tested separately in integration tests - - // Call the normal login API with seeded demo user credentials - // Use demo.driver@example.com for all auth contexts (driver role) - const response = await fetch('http://localhost:3101/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: 'demo.driver@example.com', - password: 'Demo1234!', - }), - credentials: 'include', - }); - - if (!response.ok) { - throw new Error(`Normal login failed: ${response.status}`); - } - - // Extract cookies from the response - const setCookieHeader = response.headers.get('set-cookie'); - if (!setCookieHeader) { - throw new Error('No cookies set by normal login'); - } - - // Parse the Set-Cookie headers - const cookies = setCookieHeader.split(',').map(cookieStr => { - const parts = cookieStr.split(';').map(p => p.trim()); - const [nameValue, ...attributes] = parts; - const [name, value] = nameValue.split('='); - - const cookie: any = { - name, - value: decodeURIComponent(value), - domain: 'localhost', - path: '/', - expires: Math.floor(Date.now() / 1000) + 3600, // 1 hour - httpOnly: false, - secure: false, - sameSite: 'Lax' as const - }; - - for (const attr of attributes) { - const [attrName, attrValue] = attr.split('='); - const lowerName = attrName.toLowerCase(); - - if (lowerName === 'path') cookie.path = attrValue; - else if (lowerName === 'httponly') cookie.httpOnly = true; - else if (lowerName === 'secure') cookie.secure = true; - else if (lowerName === 'samesite') cookie.sameSite = attrValue as any; - else if (lowerName === 'domain') { - // Skip domain from API - we'll use localhost - } - else if (lowerName === 'max-age') cookie.expires = Math.floor(Date.now() / 1000) + parseInt(attrValue); - } - - // For Docker/local testing, ensure cookies work with localhost - // Playwright's context.addCookies requires specific settings for localhost - if (cookie.domain === 'localhost') { - cookie.secure = false; // Localhost doesn't need HTTPS - // Keep sameSite as provided by API, but ensure it's compatible - if (cookie.sameSite === 'None') { - // For SameSite=None, we need Secure=true, but localhost doesn't support it - // So we fall back to Lax for local testing - cookie.sameSite = 'Lax'; - } - } - - return cookie; - }); - - // Apply session drift or fault modes if specified - if (options.sessionDrift || options.faultMode) { - const sessionCookie = cookies.find(c => c.name === 'gp_session'); - - if (sessionCookie) { - if (options.sessionDrift) { - sessionCookie.value = `drift-${options.sessionDrift}-${sessionCookie.value}`; - } - - if (options.faultMode) { - cookies.push({ - name: 'gridpilot_fault_mode', - value: options.faultMode, - domain, - path: '/', - expires: Math.floor(Date.now() / 1000) + 3600, - httpOnly: false, - secure: false, - sameSite: 'Lax' as const - }); - } - } - } - - // Clear existing cookies and add the new ones - await context.clearCookies(); - await context.addCookies(cookies); -} - -export type ConsoleCapture = { - consoleErrors: string[]; - pageErrors: string[]; -}; - -export function attachConsoleErrorCapture(page: Page): ConsoleCapture { - const consoleErrors: string[] = []; - const pageErrors: string[] = []; - - page.on('pageerror', (err) => { - pageErrors.push(String(err)); - }); - - page.on('console', (msg) => { - const type = msg.type(); - if (type !== 'error') return; - - const text = msg.text(); - - // Filter known benign warnings (keep small + generic). - if (text.includes('Download the React DevTools')) return; - - // Next/Image accessibility warning (not a runtime failure for smoke coverage). - if (text.includes('Image is missing required "alt" property')) return; - - // React controlled instead of setting `selected` on