Compare commits
1 Commits
tests/cont
...
setup/ci
| Author | SHA1 | Date | |
|---|---|---|---|
| 5612df2e33 |
186
.github/workflows/ci.yml
vendored
Normal file
186
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# Job 1: Lint and Typecheck (Fast feedback)
|
||||
lint-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run ESLint
|
||||
run: npm run lint
|
||||
|
||||
- name: Run Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
# Job 2: Unit and Integration Tests
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-typecheck
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Run Integration Tests
|
||||
run: npm run test:integration
|
||||
|
||||
# Job 3: Contract Tests (API/Website compatibility)
|
||||
contract-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-typecheck
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run API Contract Validation
|
||||
run: npm run test:api:contracts
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
run: npm run api:generate-spec
|
||||
|
||||
- name: Generate TypeScript types
|
||||
run: npm run api:generate-types
|
||||
|
||||
- name: Run Contract Compatibility Check
|
||||
run: npm run test:contract:compatibility
|
||||
|
||||
- name: Verify Website Type Checking
|
||||
run: npm run website:type-check
|
||||
|
||||
- name: Upload generated types as artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: generated-types
|
||||
path: apps/website/lib/types/generated/
|
||||
retention-days: 7
|
||||
|
||||
# Job 4: E2E Tests (Only on main/develop push, not on PRs)
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, tests, contract-tests]
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: npm run test:e2e
|
||||
|
||||
# Job 5: Comment PR with results (Only on PRs)
|
||||
comment-pr:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, tests, contract-tests]
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- name: Comment PR with results
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read any contract change reports
|
||||
const reportPath = path.join(process.cwd(), 'contract-report.json');
|
||||
if (fs.existsSync(reportPath)) {
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||
|
||||
const comment = `
|
||||
## 🔍 CI Results
|
||||
|
||||
✅ **All checks passed!**
|
||||
|
||||
### Changes Summary:
|
||||
- Total changes: ${report.totalChanges}
|
||||
- Breaking changes: ${report.breakingChanges}
|
||||
- Added: ${report.added}
|
||||
- Removed: ${report.removed}
|
||||
- Modified: ${report.modified}
|
||||
|
||||
Generated types are available as artifacts.
|
||||
`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
|
||||
# Job 6: Commit generated types (Only on main branch push)
|
||||
commit-types:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-typecheck, tests, contract-tests]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate and snapshot types
|
||||
run: |
|
||||
npm run api:generate-spec
|
||||
npm run api:generate-types
|
||||
|
||||
- name: Commit generated types
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add apps/website/lib/types/generated/
|
||||
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
|
||||
git push
|
||||
110
.github/workflows/contract-testing.yml
vendored
110
.github/workflows/contract-testing.yml
vendored
@@ -1,110 +0,0 @@
|
||||
name: Contract Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
contract-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run API contract validation
|
||||
run: npm run test:api:contracts
|
||||
|
||||
- name: Generate OpenAPI spec
|
||||
run: npm run api:generate-spec
|
||||
|
||||
- name: Generate TypeScript types
|
||||
run: npm run api:generate-types
|
||||
|
||||
- name: Run contract compatibility check
|
||||
run: npm run test:contract:compatibility
|
||||
|
||||
- name: Verify website type checking
|
||||
run: npm run website:type-check
|
||||
|
||||
- name: Upload generated types as artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: generated-types
|
||||
path: apps/website/lib/types/generated/
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment PR with results
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Read any contract change reports
|
||||
const reportPath = path.join(process.cwd(), 'contract-report.json');
|
||||
if (fs.existsSync(reportPath)) {
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
||||
|
||||
const comment = `
|
||||
## 🔍 Contract Testing Results
|
||||
|
||||
✅ **All contract tests passed!**
|
||||
|
||||
### Changes Summary:
|
||||
- Total changes: ${report.totalChanges}
|
||||
- Breaking changes: ${report.breakingChanges}
|
||||
- Added: ${report.added}
|
||||
- Removed: ${report.removed}
|
||||
- Modified: ${report.modified}
|
||||
|
||||
Generated types are available as artifacts.
|
||||
`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
}
|
||||
|
||||
contract-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Generate and snapshot types
|
||||
run: |
|
||||
npm run api:generate-spec
|
||||
npm run api:generate-types
|
||||
|
||||
- name: Commit generated types
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add apps/website/lib/types/generated/
|
||||
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
|
||||
git push
|
||||
@@ -1 +1 @@
|
||||
npm test
|
||||
npx lint-staged
|
||||
28
README.md
28
README.md
@@ -56,7 +56,7 @@ npm test
|
||||
Individual applications support hot reload and watch mode during development:
|
||||
|
||||
- **web-api**: Backend REST API server
|
||||
- **web-client**: Frontend React application
|
||||
- **web-client**: Frontend React application
|
||||
- **companion**: Desktop companion application
|
||||
|
||||
## Testing Commands
|
||||
@@ -64,12 +64,28 @@ Individual applications support hot reload and watch mode during development:
|
||||
GridPilot follows strict BDD (Behavior-Driven Development) with comprehensive test coverage.
|
||||
|
||||
### Local Verification Pipeline
|
||||
Run this sequence before pushing to ensure correctness:
|
||||
```bash
|
||||
npm run lint && npm run typecheck && npm run test:unit && npm run test:integration
|
||||
```
|
||||
|
||||
GridPilot uses **lint-staged** to automatically validate only changed files on commit:
|
||||
|
||||
- `eslint --fix` runs on changed JS/TS/TSX files
|
||||
- `vitest related --run` runs tests related to changed files
|
||||
- `prettier --write` formats JSON, MD, and YAML files
|
||||
|
||||
This ensures fast commits without running the full test suite.
|
||||
|
||||
### Pre-Push Hook
|
||||
|
||||
A **pre-push hook** runs the full verification pipeline before pushing to remote:
|
||||
|
||||
- `npm run lint` - Check for linting errors
|
||||
- `npm run typecheck` - Verify TypeScript types
|
||||
- `npm run test:unit` - Run unit tests
|
||||
- `npm run test:integration` - Run integration tests
|
||||
|
||||
You can skip this with `git push --no-verify` if needed.
|
||||
|
||||
### Individual Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
@@ -147,4 +163,4 @@ Comprehensive documentation is available in the [`/docs`](docs/) directory:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
25
package-lock.json
generated
25
package-lock.json
generated
@@ -251,25 +251,6 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"apps/companion/node_modules/@types/react": {
|
||||
"version": "18.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"apps/companion/node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"apps/companion/node_modules/path-to-regexp": {
|
||||
"version": "8.3.0",
|
||||
"license": "MIT",
|
||||
@@ -4736,12 +4717,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
|
||||
13
package.json
13
package.json
@@ -45,6 +45,7 @@
|
||||
"glob": "^13.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"openapi-typescript": "^7.4.3",
|
||||
"prettier": "^3.0.0",
|
||||
"puppeteer": "^24.31.0",
|
||||
@@ -128,6 +129,7 @@
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:watch": "vitest watch",
|
||||
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
|
||||
"verify": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration",
|
||||
"typecheck": "npm run typecheck:targets",
|
||||
"typecheck:grep": "npm run typescript",
|
||||
"typecheck:root": "npx tsc --noEmit --project tsconfig.json",
|
||||
@@ -139,10 +141,19 @@
|
||||
"website:start": "npm run start --workspace=@gridpilot/website",
|
||||
"website:type-check": "npm run type-check --workspace=@gridpilot/website"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"vitest related --run"
|
||||
],
|
||||
"*.{json,md,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"core/*",
|
||||
"apps/*",
|
||||
"testing/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
100
plans/ci-optimization.md
Normal file
100
plans/ci-optimization.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# CI/CD & Dev Experience Optimization Plan
|
||||
|
||||
## Current Situation
|
||||
|
||||
- **Husky `pre-commit`**: Runs `npm test` (Vitest) on every commit. This likely runs the entire test suite, which is slow and frustrating for developers.
|
||||
- **Gitea Actions**: Currently only has `contract-testing.yml`.
|
||||
- **Missing**: No automated linting or type-checking in CI, no tiered testing strategy.
|
||||
|
||||
## Proposed Strategy: The "Fast Feedback Loop"
|
||||
|
||||
We will implement a tiered approach to balance speed and safety.
|
||||
|
||||
### 1. Local Development (Husky + lint-staged)
|
||||
|
||||
**Goal**: Prevent obvious errors from entering the repo without slowing down the dev.
|
||||
|
||||
- **Trigger**: `pre-commit`
|
||||
- **Action**: Only run on **staged files**.
|
||||
- **Tasks**:
|
||||
- `eslint --fix`
|
||||
- `prettier --write`
|
||||
- `vitest related` (only run tests related to changed files)
|
||||
|
||||
### 2. Pull Request (Gitea Actions)
|
||||
|
||||
**Goal**: Ensure the branch is stable and doesn't break the build or other modules.
|
||||
|
||||
- **Trigger**: PR creation and updates.
|
||||
- **Tasks**:
|
||||
- Full `lint`
|
||||
- Full `typecheck` (crucial for monorepo integrity)
|
||||
- Full `unit tests`
|
||||
- `integration tests`
|
||||
- `contract tests`
|
||||
|
||||
### 3. Merge to Main / Release (Gitea Actions)
|
||||
|
||||
**Goal**: Final verification before deployment.
|
||||
|
||||
- **Trigger**: Push to `main` or `develop`.
|
||||
- **Tasks**:
|
||||
- Everything from PR stage.
|
||||
- `e2e tests` (Playwright) - these are the slowest and most expensive.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Install and Configure `lint-staged`
|
||||
|
||||
We need to add `lint-staged` to [`package.json`](package.json) and update the Husky hook.
|
||||
|
||||
### Step 2: Optimize Husky Hook
|
||||
|
||||
Update [`.husky/pre-commit`](.husky/pre-commit) to run `npx lint-staged` instead of `npm test`.
|
||||
|
||||
### Step 3: Create Comprehensive CI Workflow
|
||||
|
||||
Create `.github/workflows/ci.yml` (Gitea Actions compatible) to handle the heavy lifting.
|
||||
|
||||
---
|
||||
|
||||
## Workflow Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Developer Commits] --> B{Husky pre-commit}
|
||||
B -->|lint-staged| C[Lint/Format Changed Files]
|
||||
C --> D[Run Related Tests]
|
||||
D --> E[Commit Success]
|
||||
|
||||
E --> F[Push to PR]
|
||||
F --> G{Gitea CI PR Job}
|
||||
G --> H[Full Lint & Typecheck]
|
||||
G --> I[Full Unit & Integration Tests]
|
||||
G --> J[Contract Tests]
|
||||
|
||||
J --> K{Merge to Main}
|
||||
K --> L{Gitea CI Main Job}
|
||||
L --> M[All PR Checks]
|
||||
L --> N[Full E2E Tests]
|
||||
N --> O[Deploy/Release]
|
||||
```
|
||||
|
||||
## Proposed `lint-staged` Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"*.{js,ts,tsx}": ["eslint --fix", "vitest related --run"],
|
||||
"*.{json,md,yml}": ["prettier --write"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Questions for the User
|
||||
|
||||
1. Do you want to include `typecheck` in the `pre-commit` hook? (Note: `tsc` doesn't support linting only changed files easily, so it usually checks the whole project, which might be slow).
|
||||
2. Should we run `integration tests` on every PR, or only on merge to `main`?
|
||||
3. Are there specific directories that should be excluded from this automated flow?
|
||||
@@ -1,923 +0,0 @@
|
||||
/**
|
||||
* Contract Validation Tests for Admin Module
|
||||
*
|
||||
* These tests validate that the admin API DTOs and OpenAPI spec are consistent
|
||||
* and that the generated types will be compatible with the website admin client.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
$ref?: string;
|
||||
items?: OpenAPISchema;
|
||||
properties?: Record<string, OpenAPISchema>;
|
||||
required?: string[];
|
||||
enum?: string[];
|
||||
nullable?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface OpenAPISpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
description: string;
|
||||
version: string;
|
||||
};
|
||||
paths: Record<string, any>;
|
||||
components: {
|
||||
schemas: Record<string, OpenAPISchema>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('Admin Module Contract Validation', () => {
|
||||
const apiRoot = path.join(__dirname, '../..');
|
||||
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
|
||||
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
|
||||
const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types');
|
||||
|
||||
describe('OpenAPI Spec Integrity for Admin Endpoints', () => {
|
||||
it('should have admin endpoints defined in OpenAPI spec', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check for admin endpoints
|
||||
expect(spec.paths['/admin/users']).toBeDefined();
|
||||
expect(spec.paths['/admin/dashboard/stats']).toBeDefined();
|
||||
|
||||
// Verify GET methods exist
|
||||
expect(spec.paths['/admin/users'].get).toBeDefined();
|
||||
expect(spec.paths['/admin/dashboard/stats'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have ListUsersRequestDto schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['ListUsersRequestDto'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify optional query parameters
|
||||
expect(schema.properties?.role).toBeDefined();
|
||||
expect(schema.properties?.status).toBeDefined();
|
||||
expect(schema.properties?.email).toBeDefined();
|
||||
expect(schema.properties?.search).toBeDefined();
|
||||
expect(schema.properties?.page).toBeDefined();
|
||||
expect(schema.properties?.limit).toBeDefined();
|
||||
expect(schema.properties?.sortBy).toBeDefined();
|
||||
expect(schema.properties?.sortDirection).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have UserResponseDto schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['UserResponseDto'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('id');
|
||||
expect(schema.required).toContain('email');
|
||||
expect(schema.required).toContain('displayName');
|
||||
expect(schema.required).toContain('roles');
|
||||
expect(schema.required).toContain('status');
|
||||
expect(schema.required).toContain('isSystemAdmin');
|
||||
expect(schema.required).toContain('createdAt');
|
||||
expect(schema.required).toContain('updatedAt');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.id?.type).toBe('string');
|
||||
expect(schema.properties?.email?.type).toBe('string');
|
||||
expect(schema.properties?.displayName?.type).toBe('string');
|
||||
expect(schema.properties?.roles?.type).toBe('array');
|
||||
expect(schema.properties?.status?.type).toBe('string');
|
||||
expect(schema.properties?.isSystemAdmin?.type).toBe('boolean');
|
||||
expect(schema.properties?.createdAt?.type).toBe('string');
|
||||
expect(schema.properties?.updatedAt?.type).toBe('string');
|
||||
|
||||
// Verify optional fields
|
||||
expect(schema.properties?.lastLoginAt).toBeDefined();
|
||||
expect(schema.properties?.lastLoginAt?.nullable).toBe(true);
|
||||
expect(schema.properties?.primaryDriverId).toBeDefined();
|
||||
expect(schema.properties?.primaryDriverId?.nullable).toBe(true);
|
||||
});
|
||||
|
||||
it('should have UserListResponseDto schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['UserListResponseDto'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('users');
|
||||
expect(schema.required).toContain('total');
|
||||
expect(schema.required).toContain('page');
|
||||
expect(schema.required).toContain('limit');
|
||||
expect(schema.required).toContain('totalPages');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.users?.type).toBe('array');
|
||||
expect(schema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
|
||||
expect(schema.properties?.total?.type).toBe('number');
|
||||
expect(schema.properties?.page?.type).toBe('number');
|
||||
expect(schema.properties?.limit?.type).toBe('number');
|
||||
expect(schema.properties?.totalPages?.type).toBe('number');
|
||||
});
|
||||
|
||||
it('should have DashboardStatsResponseDto schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['DashboardStatsResponseDto'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('totalUsers');
|
||||
expect(schema.required).toContain('activeUsers');
|
||||
expect(schema.required).toContain('suspendedUsers');
|
||||
expect(schema.required).toContain('deletedUsers');
|
||||
expect(schema.required).toContain('systemAdmins');
|
||||
expect(schema.required).toContain('recentLogins');
|
||||
expect(schema.required).toContain('newUsersToday');
|
||||
expect(schema.required).toContain('userGrowth');
|
||||
expect(schema.required).toContain('roleDistribution');
|
||||
expect(schema.required).toContain('statusDistribution');
|
||||
expect(schema.required).toContain('activityTimeline');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.totalUsers?.type).toBe('number');
|
||||
expect(schema.properties?.activeUsers?.type).toBe('number');
|
||||
expect(schema.properties?.suspendedUsers?.type).toBe('number');
|
||||
expect(schema.properties?.deletedUsers?.type).toBe('number');
|
||||
expect(schema.properties?.systemAdmins?.type).toBe('number');
|
||||
expect(schema.properties?.recentLogins?.type).toBe('number');
|
||||
expect(schema.properties?.newUsersToday?.type).toBe('number');
|
||||
|
||||
// Verify nested objects
|
||||
expect(schema.properties?.userGrowth?.type).toBe('array');
|
||||
expect(schema.properties?.roleDistribution?.type).toBe('array');
|
||||
expect(schema.properties?.statusDistribution?.type).toBe('object');
|
||||
expect(schema.properties?.activityTimeline?.type).toBe('array');
|
||||
});
|
||||
|
||||
it('should have proper query parameter validation in OpenAPI', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
expect(listUsersPath).toBeDefined();
|
||||
|
||||
// Verify query parameters are documented
|
||||
const params = listUsersPath.parameters || [];
|
||||
const paramNames = params.map((p: any) => p.name);
|
||||
|
||||
// These should be query parameters based on the DTO
|
||||
expect(paramNames).toContain('role');
|
||||
expect(paramNames).toContain('status');
|
||||
expect(paramNames).toContain('email');
|
||||
expect(paramNames).toContain('search');
|
||||
expect(paramNames).toContain('page');
|
||||
expect(paramNames).toContain('limit');
|
||||
expect(paramNames).toContain('sortBy');
|
||||
expect(paramNames).toContain('sortDirection');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DTO Consistency', () => {
|
||||
it('should have generated DTO files for admin schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||
const generatedDTOs = generatedFiles
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => f.replace('.ts', ''));
|
||||
|
||||
// Check for admin-related DTOs
|
||||
const adminDTOs = [
|
||||
'ListUsersRequestDto',
|
||||
'UserResponseDto',
|
||||
'UserListResponseDto',
|
||||
'DashboardStatsResponseDto',
|
||||
];
|
||||
|
||||
for (const dtoName of adminDTOs) {
|
||||
expect(spec.components.schemas[dtoName]).toBeDefined();
|
||||
expect(generatedDTOs).toContain(dtoName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent property types between DTOs and schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
// Test ListUsersRequestDto
|
||||
const listUsersSchema = schemas['ListUsersRequestDto'];
|
||||
const listUsersDtoPath = path.join(generatedTypesDir, 'ListUsersRequestDto.ts');
|
||||
const listUsersDtoExists = await fs.access(listUsersDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (listUsersDtoExists) {
|
||||
const listUsersDtoContent = await fs.readFile(listUsersDtoPath, 'utf-8');
|
||||
|
||||
// Check that all properties are present
|
||||
if (listUsersSchema.properties) {
|
||||
for (const propName of Object.keys(listUsersSchema.properties)) {
|
||||
expect(listUsersDtoContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test UserResponseDto
|
||||
const userSchema = schemas['UserResponseDto'];
|
||||
const userDtoPath = path.join(generatedTypesDir, 'UserResponseDto.ts');
|
||||
const userDtoExists = await fs.access(userDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (userDtoExists) {
|
||||
const userDtoContent = await fs.readFile(userDtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (userSchema.required) {
|
||||
for (const requiredProp of userSchema.required) {
|
||||
expect(userDtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all properties are present
|
||||
if (userSchema.properties) {
|
||||
for (const propName of Object.keys(userSchema.properties)) {
|
||||
expect(userDtoContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test UserListResponseDto
|
||||
const userListSchema = schemas['UserListResponseDto'];
|
||||
const userListDtoPath = path.join(generatedTypesDir, 'UserListResponseDto.ts');
|
||||
const userListDtoExists = await fs.access(userListDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (userListDtoExists) {
|
||||
const userListDtoContent = await fs.readFile(userListDtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (userListSchema.required) {
|
||||
for (const requiredProp of userListSchema.required) {
|
||||
expect(userListDtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test DashboardStatsResponseDto
|
||||
const dashboardSchema = schemas['DashboardStatsResponseDto'];
|
||||
const dashboardDtoPath = path.join(generatedTypesDir, 'DashboardStatsResponseDto.ts');
|
||||
const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (dashboardDtoExists) {
|
||||
const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (dashboardSchema.required) {
|
||||
for (const requiredProp of dashboardSchema.required) {
|
||||
expect(dashboardDtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have TBD admin types defined', async () => {
|
||||
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
||||
const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false);
|
||||
|
||||
expect(adminTypesExists).toBe(true);
|
||||
|
||||
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
||||
|
||||
// Verify UserDto interface
|
||||
expect(adminTypesContent).toContain('export interface AdminUserDto');
|
||||
expect(adminTypesContent).toContain('id: string');
|
||||
expect(adminTypesContent).toContain('email: string');
|
||||
expect(adminTypesContent).toContain('displayName: string');
|
||||
expect(adminTypesContent).toContain('roles: string[]');
|
||||
expect(adminTypesContent).toContain('status: string');
|
||||
expect(adminTypesContent).toContain('isSystemAdmin: boolean');
|
||||
expect(adminTypesContent).toContain('createdAt: string');
|
||||
expect(adminTypesContent).toContain('updatedAt: string');
|
||||
expect(adminTypesContent).toContain('lastLoginAt?: string');
|
||||
expect(adminTypesContent).toContain('primaryDriverId?: string');
|
||||
|
||||
// Verify UserListResponse interface
|
||||
expect(adminTypesContent).toContain('export interface AdminUserListResponseDto');
|
||||
expect(adminTypesContent).toContain('users: AdminUserDto[]');
|
||||
expect(adminTypesContent).toContain('total: number');
|
||||
expect(adminTypesContent).toContain('page: number');
|
||||
expect(adminTypesContent).toContain('limit: number');
|
||||
expect(adminTypesContent).toContain('totalPages: number');
|
||||
|
||||
// Verify DashboardStats interface
|
||||
expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto');
|
||||
expect(adminTypesContent).toContain('totalUsers: number');
|
||||
expect(adminTypesContent).toContain('activeUsers: number');
|
||||
expect(adminTypesContent).toContain('suspendedUsers: number');
|
||||
expect(adminTypesContent).toContain('deletedUsers: number');
|
||||
expect(adminTypesContent).toContain('systemAdmins: number');
|
||||
expect(adminTypesContent).toContain('recentLogins: number');
|
||||
expect(adminTypesContent).toContain('newUsersToday: number');
|
||||
|
||||
// Verify ListUsersQuery interface
|
||||
expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto');
|
||||
expect(adminTypesContent).toContain('role?: string');
|
||||
expect(adminTypesContent).toContain('status?: string');
|
||||
expect(adminTypesContent).toContain('email?: string');
|
||||
expect(adminTypesContent).toContain('search?: string');
|
||||
expect(adminTypesContent).toContain('page?: number');
|
||||
expect(adminTypesContent).toContain('limit?: number');
|
||||
expect(adminTypesContent).toContain("sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status'");
|
||||
expect(adminTypesContent).toContain("sortDirection?: 'asc' | 'desc'");
|
||||
});
|
||||
|
||||
it('should have admin types re-exported from main types file', async () => {
|
||||
const adminTypesPath = path.join(websiteTypesDir, 'admin.ts');
|
||||
const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false);
|
||||
|
||||
expect(adminTypesExists).toBe(true);
|
||||
|
||||
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
||||
|
||||
// Verify re-exports
|
||||
expect(adminTypesContent).toContain('AdminUserDto as UserDto');
|
||||
expect(adminTypesContent).toContain('AdminUserListResponseDto as UserListResponse');
|
||||
expect(adminTypesContent).toContain('AdminListUsersQueryDto as ListUsersQuery');
|
||||
expect(adminTypesContent).toContain('AdminDashboardStatsDto as DashboardStats');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin API Client Contract', () => {
|
||||
it('should have AdminApiClient defined', async () => {
|
||||
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
||||
const adminApiClientExists = await fs.access(adminApiClientPath).then(() => true).catch(() => false);
|
||||
|
||||
expect(adminApiClientExists).toBe(true);
|
||||
|
||||
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
||||
|
||||
// Verify class definition
|
||||
expect(adminApiClientContent).toContain('export class AdminApiClient');
|
||||
expect(adminApiClientContent).toContain('extends BaseApiClient');
|
||||
|
||||
// Verify methods exist
|
||||
expect(adminApiClientContent).toContain('async listUsers');
|
||||
expect(adminApiClientContent).toContain('async getUser');
|
||||
expect(adminApiClientContent).toContain('async updateUserRoles');
|
||||
expect(adminApiClientContent).toContain('async updateUserStatus');
|
||||
expect(adminApiClientContent).toContain('async deleteUser');
|
||||
expect(adminApiClientContent).toContain('async createUser');
|
||||
expect(adminApiClientContent).toContain('async getDashboardStats');
|
||||
|
||||
// Verify method signatures
|
||||
expect(adminApiClientContent).toContain('listUsers(query: ListUsersQuery = {})');
|
||||
expect(adminApiClientContent).toContain('getUser(userId: string)');
|
||||
expect(adminApiClientContent).toContain('updateUserRoles(userId: string, roles: string[])');
|
||||
expect(adminApiClientContent).toContain('updateUserStatus(userId: string, status: string)');
|
||||
expect(adminApiClientContent).toContain('deleteUser(userId: string)');
|
||||
expect(adminApiClientContent).toContain('createUser(userData: {');
|
||||
expect(adminApiClientContent).toContain('getDashboardStats()');
|
||||
});
|
||||
|
||||
it('should have proper request construction in listUsers method', async () => {
|
||||
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
||||
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
||||
|
||||
// Verify query parameter construction
|
||||
expect(adminApiClientContent).toContain('const params = new URLSearchParams()');
|
||||
expect(adminApiClientContent).toContain("params.append('role', query.role)");
|
||||
expect(adminApiClientContent).toContain("params.append('status', query.status)");
|
||||
expect(adminApiClientContent).toContain("params.append('email', query.email)");
|
||||
expect(adminApiClientContent).toContain("params.append('search', query.search)");
|
||||
expect(adminApiClientContent).toContain("params.append('page', query.page.toString())");
|
||||
expect(adminApiClientContent).toContain("params.append('limit', query.limit.toString())");
|
||||
expect(adminApiClientContent).toContain("params.append('sortBy', query.sortBy)");
|
||||
expect(adminApiClientContent).toContain("params.append('sortDirection', query.sortDirection)");
|
||||
|
||||
// Verify endpoint construction
|
||||
expect(adminApiClientContent).toContain("return this.get<UserListResponse>(`/admin/users?${params.toString()}`)");
|
||||
});
|
||||
|
||||
it('should have proper request construction in createUser method', async () => {
|
||||
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
||||
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
||||
|
||||
// Verify POST request with userData
|
||||
expect(adminApiClientContent).toContain("return this.post<UserDto>(`/admin/users`, userData)");
|
||||
});
|
||||
|
||||
it('should have proper request construction in getDashboardStats method', async () => {
|
||||
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
||||
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
||||
|
||||
// Verify GET request
|
||||
expect(adminApiClientContent).toContain("return this.get<DashboardStats>(`/admin/dashboard/stats`)");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Correctness Tests', () => {
|
||||
it('should validate ListUsersRequestDto query parameters', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['ListUsersRequestDto'];
|
||||
|
||||
// Verify all query parameters are optional (no required fields)
|
||||
expect(schema.required).toBeUndefined();
|
||||
|
||||
// Verify enum values for role
|
||||
expect(schema.properties?.role?.enum).toBeUndefined(); // No enum constraint in DTO
|
||||
|
||||
// Verify enum values for status
|
||||
expect(schema.properties?.status?.enum).toBeUndefined(); // No enum constraint in DTO
|
||||
|
||||
// Verify enum values for sortBy
|
||||
expect(schema.properties?.sortBy?.enum).toEqual([
|
||||
'email',
|
||||
'displayName',
|
||||
'createdAt',
|
||||
'lastLoginAt',
|
||||
'status'
|
||||
]);
|
||||
|
||||
// Verify enum values for sortDirection
|
||||
expect(schema.properties?.sortDirection?.enum).toEqual(['asc', 'desc']);
|
||||
expect(schema.properties?.sortDirection?.default).toBe('asc');
|
||||
|
||||
// Verify numeric constraints
|
||||
expect(schema.properties?.page?.minimum).toBe(1);
|
||||
expect(schema.properties?.page?.default).toBe(1);
|
||||
expect(schema.properties?.limit?.minimum).toBe(1);
|
||||
expect(schema.properties?.limit?.maximum).toBe(100);
|
||||
expect(schema.properties?.limit?.default).toBe(10);
|
||||
});
|
||||
|
||||
it('should validate UserResponseDto field constraints', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['UserResponseDto'];
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('id');
|
||||
expect(schema.required).toContain('email');
|
||||
expect(schema.required).toContain('displayName');
|
||||
expect(schema.required).toContain('roles');
|
||||
expect(schema.required).toContain('status');
|
||||
expect(schema.required).toContain('isSystemAdmin');
|
||||
expect(schema.required).toContain('createdAt');
|
||||
expect(schema.required).toContain('updatedAt');
|
||||
|
||||
// Verify optional fields are nullable
|
||||
expect(schema.properties?.lastLoginAt?.nullable).toBe(true);
|
||||
expect(schema.properties?.primaryDriverId?.nullable).toBe(true);
|
||||
|
||||
// Verify roles is an array
|
||||
expect(schema.properties?.roles?.type).toBe('array');
|
||||
expect(schema.properties?.roles?.items?.type).toBe('string');
|
||||
});
|
||||
|
||||
it('should validate DashboardStatsResponseDto structure', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['DashboardStatsResponseDto'];
|
||||
|
||||
// Verify all required fields
|
||||
const requiredFields = [
|
||||
'totalUsers',
|
||||
'activeUsers',
|
||||
'suspendedUsers',
|
||||
'deletedUsers',
|
||||
'systemAdmins',
|
||||
'recentLogins',
|
||||
'newUsersToday',
|
||||
'userGrowth',
|
||||
'roleDistribution',
|
||||
'statusDistribution',
|
||||
'activityTimeline'
|
||||
];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
expect(schema.required).toContain(field);
|
||||
}
|
||||
|
||||
// Verify nested object structures
|
||||
expect(schema.properties?.userGrowth?.items?.$ref).toBe('#/components/schemas/UserGrowthDto');
|
||||
expect(schema.properties?.roleDistribution?.items?.$ref).toBe('#/components/schemas/RoleDistributionDto');
|
||||
expect(schema.properties?.statusDistribution?.$ref).toBe('#/components/schemas/StatusDistributionDto');
|
||||
expect(schema.properties?.activityTimeline?.items?.$ref).toBe('#/components/schemas/ActivityTimelineDto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Handling Tests', () => {
|
||||
it('should handle successful user list response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
||||
const userSchema = spec.components.schemas['UserResponseDto'];
|
||||
|
||||
// Verify response structure
|
||||
expect(userListSchema.properties?.users).toBeDefined();
|
||||
expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
|
||||
|
||||
// Verify user object has all required fields
|
||||
expect(userSchema.required).toContain('id');
|
||||
expect(userSchema.required).toContain('email');
|
||||
expect(userSchema.required).toContain('displayName');
|
||||
expect(userSchema.required).toContain('roles');
|
||||
expect(userSchema.required).toContain('status');
|
||||
expect(userSchema.required).toContain('isSystemAdmin');
|
||||
expect(userSchema.required).toContain('createdAt');
|
||||
expect(userSchema.required).toContain('updatedAt');
|
||||
|
||||
// Verify optional fields
|
||||
expect(userSchema.properties?.lastLoginAt).toBeDefined();
|
||||
expect(userSchema.properties?.primaryDriverId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle successful dashboard stats response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
|
||||
|
||||
// Verify all required fields are present
|
||||
expect(dashboardSchema.required).toContain('totalUsers');
|
||||
expect(dashboardSchema.required).toContain('activeUsers');
|
||||
expect(dashboardSchema.required).toContain('suspendedUsers');
|
||||
expect(dashboardSchema.required).toContain('deletedUsers');
|
||||
expect(dashboardSchema.required).toContain('systemAdmins');
|
||||
expect(dashboardSchema.required).toContain('recentLogins');
|
||||
expect(dashboardSchema.required).toContain('newUsersToday');
|
||||
expect(dashboardSchema.required).toContain('userGrowth');
|
||||
expect(dashboardSchema.required).toContain('roleDistribution');
|
||||
expect(dashboardSchema.required).toContain('statusDistribution');
|
||||
expect(dashboardSchema.required).toContain('activityTimeline');
|
||||
|
||||
// Verify nested objects are properly typed
|
||||
expect(dashboardSchema.properties?.userGrowth?.type).toBe('array');
|
||||
expect(dashboardSchema.properties?.roleDistribution?.type).toBe('array');
|
||||
expect(dashboardSchema.properties?.statusDistribution?.type).toBe('object');
|
||||
expect(dashboardSchema.properties?.activityTimeline?.type).toBe('array');
|
||||
});
|
||||
|
||||
it('should handle optional fields in user response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userSchema = spec.components.schemas['UserResponseDto'];
|
||||
|
||||
// Verify optional fields are nullable
|
||||
expect(userSchema.properties?.lastLoginAt?.nullable).toBe(true);
|
||||
expect(userSchema.properties?.primaryDriverId?.nullable).toBe(true);
|
||||
|
||||
// Verify optional fields are not in required array
|
||||
expect(userSchema.required).not.toContain('lastLoginAt');
|
||||
expect(userSchema.required).not.toContain('primaryDriverId');
|
||||
});
|
||||
|
||||
it('should handle pagination fields correctly', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
||||
|
||||
// Verify pagination fields are required
|
||||
expect(userListSchema.required).toContain('total');
|
||||
expect(userListSchema.required).toContain('page');
|
||||
expect(userListSchema.required).toContain('limit');
|
||||
expect(userListSchema.required).toContain('totalPages');
|
||||
|
||||
// Verify pagination field types
|
||||
expect(userListSchema.properties?.total?.type).toBe('number');
|
||||
expect(userListSchema.properties?.page?.type).toBe('number');
|
||||
expect(userListSchema.properties?.limit?.type).toBe('number');
|
||||
expect(userListSchema.properties?.totalPages?.type).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Tests', () => {
|
||||
it('should document 403 Forbidden response for admin endpoints', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
|
||||
|
||||
// Verify 403 response is documented
|
||||
expect(listUsersPath.responses['403']).toBeDefined();
|
||||
expect(dashboardStatsPath.responses['403']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should document 401 Unauthorized response for admin endpoints', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
|
||||
|
||||
// Verify 401 response is documented
|
||||
expect(listUsersPath.responses['401']).toBeDefined();
|
||||
expect(dashboardStatsPath.responses['401']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should document 400 Bad Request response for invalid query parameters', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
|
||||
// Verify 400 response is documented
|
||||
expect(listUsersPath.responses['400']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should document 500 Internal Server Error response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
|
||||
|
||||
// Verify 500 response is documented
|
||||
expect(listUsersPath.responses['500']).toBeDefined();
|
||||
expect(dashboardStatsPath.responses['500']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should document 404 Not Found response for user operations', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check for user-specific endpoints (if they exist)
|
||||
const getUserPath = spec.paths['/admin/users/{userId}']?.get;
|
||||
if (getUserPath) {
|
||||
expect(getUserPath.responses['404']).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should document 409 Conflict response for duplicate operations', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check for create user endpoint (if it exists)
|
||||
const createUserPath = spec.paths['/admin/users']?.post;
|
||||
if (createUserPath) {
|
||||
expect(createUserPath.responses['409']).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Semantic Guarantee Tests', () => {
|
||||
it('should maintain ordering guarantees for user list', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
|
||||
// Verify sortBy and sortDirection parameters are documented
|
||||
const params = listUsersPath.parameters || [];
|
||||
const sortByParam = params.find((p: any) => p.name === 'sortBy');
|
||||
const sortDirectionParam = params.find((p: any) => p.name === 'sortDirection');
|
||||
|
||||
expect(sortByParam).toBeDefined();
|
||||
expect(sortDirectionParam).toBeDefined();
|
||||
|
||||
// Verify sortDirection has default value
|
||||
expect(sortDirectionParam?.schema?.default).toBe('asc');
|
||||
});
|
||||
|
||||
it('should validate pagination consistency', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
||||
|
||||
// Verify pagination fields are all required
|
||||
expect(userListSchema.required).toContain('page');
|
||||
expect(userListSchema.required).toContain('limit');
|
||||
expect(userListSchema.required).toContain('total');
|
||||
expect(userListSchema.required).toContain('totalPages');
|
||||
|
||||
// Verify page and limit have constraints
|
||||
const listUsersPath = spec.paths['/admin/users']?.get;
|
||||
const params = listUsersPath.parameters || [];
|
||||
const pageParam = params.find((p: any) => p.name === 'page');
|
||||
const limitParam = params.find((p: any) => p.name === 'limit');
|
||||
|
||||
expect(pageParam?.schema?.minimum).toBe(1);
|
||||
expect(pageParam?.schema?.default).toBe(1);
|
||||
expect(limitParam?.schema?.minimum).toBe(1);
|
||||
expect(limitParam?.schema?.maximum).toBe(100);
|
||||
expect(limitParam?.schema?.default).toBe(10);
|
||||
});
|
||||
|
||||
it('should validate idempotency for user status updates', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check for user status update endpoint (if it exists)
|
||||
const updateUserStatusPath = spec.paths['/admin/users/{userId}/status']?.patch;
|
||||
if (updateUserStatusPath) {
|
||||
// Verify it accepts a status parameter
|
||||
const params = updateUserStatusPath.parameters || [];
|
||||
const statusParam = params.find((p: any) => p.name === 'status');
|
||||
expect(statusParam).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate uniqueness constraints for user email', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userSchema = spec.components.schemas['UserResponseDto'];
|
||||
|
||||
// Verify email is a required field
|
||||
expect(userSchema.required).toContain('email');
|
||||
expect(userSchema.properties?.email?.type).toBe('string');
|
||||
|
||||
// Check for create user endpoint (if it exists)
|
||||
const createUserPath = spec.paths['/admin/users']?.post;
|
||||
if (createUserPath) {
|
||||
// Verify email is required in request body
|
||||
const requestBody = createUserPath.requestBody;
|
||||
if (requestBody && requestBody.content && requestBody.content['application/json']) {
|
||||
const schema = requestBody.content['application/json'].schema;
|
||||
if (schema && schema.$ref) {
|
||||
// This would reference a CreateUserDto which should have email as required
|
||||
expect(schema.$ref).toContain('CreateUserDto');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate consistency between request and response schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userSchema = spec.components.schemas['UserResponseDto'];
|
||||
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
||||
|
||||
// Verify UserListResponse contains array of UserResponse
|
||||
expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
|
||||
|
||||
// Verify UserResponse has consistent field types
|
||||
expect(userSchema.properties?.id?.type).toBe('string');
|
||||
expect(userSchema.properties?.email?.type).toBe('string');
|
||||
expect(userSchema.properties?.displayName?.type).toBe('string');
|
||||
expect(userSchema.properties?.roles?.type).toBe('array');
|
||||
expect(userSchema.properties?.status?.type).toBe('string');
|
||||
expect(userSchema.properties?.isSystemAdmin?.type).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should validate semantic consistency in dashboard stats', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
|
||||
|
||||
// Verify totalUsers >= activeUsers + suspendedUsers + deletedUsers
|
||||
// (This is a semantic guarantee that should be enforced by the backend)
|
||||
expect(dashboardSchema.properties?.totalUsers).toBeDefined();
|
||||
expect(dashboardSchema.properties?.activeUsers).toBeDefined();
|
||||
expect(dashboardSchema.properties?.suspendedUsers).toBeDefined();
|
||||
expect(dashboardSchema.properties?.deletedUsers).toBeDefined();
|
||||
|
||||
// Verify systemAdmins is a subset of totalUsers
|
||||
expect(dashboardSchema.properties?.systemAdmins).toBeDefined();
|
||||
|
||||
// Verify recentLogins and newUsersToday are non-negative
|
||||
expect(dashboardSchema.properties?.recentLogins).toBeDefined();
|
||||
expect(dashboardSchema.properties?.newUsersToday).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate pagination metadata consistency', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
||||
|
||||
// Verify pagination metadata is always present
|
||||
expect(userListSchema.required).toContain('page');
|
||||
expect(userListSchema.required).toContain('limit');
|
||||
expect(userListSchema.required).toContain('total');
|
||||
expect(userListSchema.required).toContain('totalPages');
|
||||
|
||||
// Verify totalPages calculation is consistent
|
||||
// totalPages should be >= 1 and should be calculated as Math.ceil(total / limit)
|
||||
expect(userListSchema.properties?.totalPages?.type).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin Module Integration Tests', () => {
|
||||
it('should have consistent types between API DTOs and website types', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
||||
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
||||
|
||||
// Verify UserDto interface matches UserResponseDto schema
|
||||
const userSchema = spec.components.schemas['UserResponseDto'];
|
||||
expect(adminTypesContent).toContain('export interface AdminUserDto');
|
||||
|
||||
// Check all required fields from schema are in interface
|
||||
for (const field of userSchema.required || []) {
|
||||
expect(adminTypesContent).toContain(`${field}:`);
|
||||
}
|
||||
|
||||
// Check all properties from schema are in interface
|
||||
if (userSchema.properties) {
|
||||
for (const propName of Object.keys(userSchema.properties)) {
|
||||
expect(adminTypesContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent query types between API and website', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
||||
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
||||
|
||||
// Verify ListUsersQuery interface matches ListUsersRequestDto schema
|
||||
const listUsersSchema = spec.components.schemas['ListUsersRequestDto'];
|
||||
expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto');
|
||||
|
||||
// Check all properties from schema are in interface
|
||||
if (listUsersSchema.properties) {
|
||||
for (const propName of Object.keys(listUsersSchema.properties)) {
|
||||
expect(adminTypesContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent response types between API and website', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
||||
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
||||
|
||||
// Verify UserListResponse interface matches UserListResponseDto schema
|
||||
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
||||
expect(adminTypesContent).toContain('export interface AdminUserListResponseDto');
|
||||
|
||||
// Check all required fields from schema are in interface
|
||||
for (const field of userListSchema.required || []) {
|
||||
expect(adminTypesContent).toContain(`${field}:`);
|
||||
}
|
||||
|
||||
// Verify DashboardStats interface matches DashboardStatsResponseDto schema
|
||||
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
|
||||
expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto');
|
||||
|
||||
// Check all required fields from schema are in interface
|
||||
for (const field of dashboardSchema.required || []) {
|
||||
expect(adminTypesContent).toContain(`${field}:`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have AdminApiClient methods matching API endpoints', async () => {
|
||||
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
||||
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
||||
|
||||
// Verify listUsers method exists and uses correct endpoint
|
||||
expect(adminApiClientContent).toContain('async listUsers');
|
||||
expect(adminApiClientContent).toContain("return this.get<UserListResponse>(`/admin/users?${params.toString()}`)");
|
||||
|
||||
// Verify getDashboardStats method exists and uses correct endpoint
|
||||
expect(adminApiClientContent).toContain('async getDashboardStats');
|
||||
expect(adminApiClientContent).toContain("return this.get<DashboardStats>(`/admin/dashboard/stats`)");
|
||||
});
|
||||
|
||||
it('should have proper error handling in AdminApiClient', async () => {
|
||||
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
||||
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
||||
|
||||
// Verify BaseApiClient is extended (which provides error handling)
|
||||
expect(adminApiClientContent).toContain('extends BaseApiClient');
|
||||
|
||||
// Verify methods use BaseApiClient methods (which handle errors)
|
||||
expect(adminApiClientContent).toContain('this.get<');
|
||||
expect(adminApiClientContent).toContain('this.post<');
|
||||
expect(adminApiClientContent).toContain('this.patch<');
|
||||
expect(adminApiClientContent).toContain('this.delete<');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,897 +0,0 @@
|
||||
/**
|
||||
* Contract Validation Tests for Analytics Module
|
||||
*
|
||||
* These tests validate that the analytics API DTOs and OpenAPI spec are consistent
|
||||
* and that the generated types will be compatible with the website analytics client.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
$ref?: string;
|
||||
items?: OpenAPISchema;
|
||||
properties?: Record<string, OpenAPISchema>;
|
||||
required?: string[];
|
||||
enum?: string[];
|
||||
nullable?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface OpenAPISpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
description: string;
|
||||
version: string;
|
||||
};
|
||||
paths: Record<string, any>;
|
||||
components: {
|
||||
schemas: Record<string, OpenAPISchema>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('Analytics Module Contract Validation', () => {
|
||||
const apiRoot = path.join(__dirname, '../..');
|
||||
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
|
||||
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
|
||||
const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types');
|
||||
|
||||
describe('OpenAPI Spec Integrity for Analytics Endpoints', () => {
|
||||
it('should have analytics endpoints defined in OpenAPI spec', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check for analytics endpoints
|
||||
expect(spec.paths['/analytics/page-view']).toBeDefined();
|
||||
expect(spec.paths['/analytics/engagement']).toBeDefined();
|
||||
expect(spec.paths['/analytics/dashboard']).toBeDefined();
|
||||
expect(spec.paths['/analytics/metrics']).toBeDefined();
|
||||
|
||||
// Verify POST methods exist for recording endpoints
|
||||
expect(spec.paths['/analytics/page-view'].post).toBeDefined();
|
||||
expect(spec.paths['/analytics/engagement'].post).toBeDefined();
|
||||
|
||||
// Verify GET methods exist for query endpoints
|
||||
expect(spec.paths['/analytics/dashboard'].get).toBeDefined();
|
||||
expect(spec.paths['/analytics/metrics'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have RecordPageViewInputDTO schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('entityType');
|
||||
expect(schema.required).toContain('entityId');
|
||||
expect(schema.required).toContain('visitorType');
|
||||
expect(schema.required).toContain('sessionId');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.entityType?.type).toBe('string');
|
||||
expect(schema.properties?.entityId?.type).toBe('string');
|
||||
expect(schema.properties?.visitorType?.type).toBe('string');
|
||||
expect(schema.properties?.sessionId?.type).toBe('string');
|
||||
|
||||
// Verify optional fields
|
||||
expect(schema.properties?.visitorId).toBeDefined();
|
||||
expect(schema.properties?.referrer).toBeDefined();
|
||||
expect(schema.properties?.userAgent).toBeDefined();
|
||||
expect(schema.properties?.country).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have RecordPageViewOutputDTO schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordPageViewOutputDTO'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('pageViewId');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.pageViewId?.type).toBe('string');
|
||||
});
|
||||
|
||||
it('should have RecordEngagementInputDTO schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('action');
|
||||
expect(schema.required).toContain('entityType');
|
||||
expect(schema.required).toContain('entityId');
|
||||
expect(schema.required).toContain('actorType');
|
||||
expect(schema.required).toContain('sessionId');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.action?.type).toBe('string');
|
||||
expect(schema.properties?.entityType?.type).toBe('string');
|
||||
expect(schema.properties?.entityId?.type).toBe('string');
|
||||
expect(schema.properties?.actorType?.type).toBe('string');
|
||||
expect(schema.properties?.sessionId?.type).toBe('string');
|
||||
|
||||
// Verify optional fields
|
||||
expect(schema.properties?.actorId).toBeDefined();
|
||||
expect(schema.properties?.metadata).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have RecordEngagementOutputDTO schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordEngagementOutputDTO'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('eventId');
|
||||
expect(schema.required).toContain('engagementWeight');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.eventId?.type).toBe('string');
|
||||
expect(schema.properties?.engagementWeight?.type).toBe('number');
|
||||
});
|
||||
|
||||
it('should have GetAnalyticsMetricsOutputDTO schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('pageViews');
|
||||
expect(schema.required).toContain('uniqueVisitors');
|
||||
expect(schema.required).toContain('averageSessionDuration');
|
||||
expect(schema.required).toContain('bounceRate');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.pageViews?.type).toBe('number');
|
||||
expect(schema.properties?.uniqueVisitors?.type).toBe('number');
|
||||
expect(schema.properties?.averageSessionDuration?.type).toBe('number');
|
||||
expect(schema.properties?.bounceRate?.type).toBe('number');
|
||||
});
|
||||
|
||||
it('should have GetDashboardDataOutputDTO schema defined', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['GetDashboardDataOutputDTO'];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// Verify required fields
|
||||
expect(schema.required).toContain('totalUsers');
|
||||
expect(schema.required).toContain('activeUsers');
|
||||
expect(schema.required).toContain('totalRaces');
|
||||
expect(schema.required).toContain('totalLeagues');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.totalUsers?.type).toBe('number');
|
||||
expect(schema.properties?.activeUsers?.type).toBe('number');
|
||||
expect(schema.properties?.totalRaces?.type).toBe('number');
|
||||
expect(schema.properties?.totalLeagues?.type).toBe('number');
|
||||
});
|
||||
|
||||
it('should have proper request/response structure for page-view endpoint', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pageViewPath = spec.paths['/analytics/page-view']?.post;
|
||||
expect(pageViewPath).toBeDefined();
|
||||
|
||||
// Verify request body
|
||||
const requestBody = pageViewPath.requestBody;
|
||||
expect(requestBody).toBeDefined();
|
||||
expect(requestBody.content['application/json']).toBeDefined();
|
||||
expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordPageViewInputDTO');
|
||||
|
||||
// Verify response
|
||||
const response201 = pageViewPath.responses['201'];
|
||||
expect(response201).toBeDefined();
|
||||
expect(response201.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordPageViewOutputDTO');
|
||||
});
|
||||
|
||||
it('should have proper request/response structure for engagement endpoint', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const engagementPath = spec.paths['/analytics/engagement']?.post;
|
||||
expect(engagementPath).toBeDefined();
|
||||
|
||||
// Verify request body
|
||||
const requestBody = engagementPath.requestBody;
|
||||
expect(requestBody).toBeDefined();
|
||||
expect(requestBody.content['application/json']).toBeDefined();
|
||||
expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordEngagementInputDTO');
|
||||
|
||||
// Verify response
|
||||
const response201 = engagementPath.responses['201'];
|
||||
expect(response201).toBeDefined();
|
||||
expect(response201.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordEngagementOutputDTO');
|
||||
});
|
||||
|
||||
it('should have proper response structure for metrics endpoint', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const metricsPath = spec.paths['/analytics/metrics']?.get;
|
||||
expect(metricsPath).toBeDefined();
|
||||
|
||||
// Verify response
|
||||
const response200 = metricsPath.responses['200'];
|
||||
expect(response200).toBeDefined();
|
||||
expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/GetAnalyticsMetricsOutputDTO');
|
||||
});
|
||||
|
||||
it('should have proper response structure for dashboard endpoint', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const dashboardPath = spec.paths['/analytics/dashboard']?.get;
|
||||
expect(dashboardPath).toBeDefined();
|
||||
|
||||
// Verify response
|
||||
const response200 = dashboardPath.responses['200'];
|
||||
expect(response200).toBeDefined();
|
||||
expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/GetDashboardDataOutputDTO');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DTO Consistency', () => {
|
||||
it('should have generated DTO files for analytics schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||
const generatedDTOs = generatedFiles
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => f.replace('.ts', ''));
|
||||
|
||||
// Check for analytics-related DTOs
|
||||
const analyticsDTOs = [
|
||||
'RecordPageViewInputDTO',
|
||||
'RecordPageViewOutputDTO',
|
||||
'RecordEngagementInputDTO',
|
||||
'RecordEngagementOutputDTO',
|
||||
'GetAnalyticsMetricsOutputDTO',
|
||||
'GetDashboardDataOutputDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of analyticsDTOs) {
|
||||
expect(spec.components.schemas[dtoName]).toBeDefined();
|
||||
expect(generatedDTOs).toContain(dtoName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent property types between DTOs and schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
// Test RecordPageViewInputDTO
|
||||
const pageViewSchema = schemas['RecordPageViewInputDTO'];
|
||||
const pageViewDtoPath = path.join(generatedTypesDir, 'RecordPageViewInputDTO.ts');
|
||||
const pageViewDtoExists = await fs.access(pageViewDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (pageViewDtoExists) {
|
||||
const pageViewDtoContent = await fs.readFile(pageViewDtoPath, 'utf-8');
|
||||
|
||||
// Check that all properties are present
|
||||
if (pageViewSchema.properties) {
|
||||
for (const propName of Object.keys(pageViewSchema.properties)) {
|
||||
expect(pageViewDtoContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test RecordEngagementInputDTO
|
||||
const engagementSchema = schemas['RecordEngagementInputDTO'];
|
||||
const engagementDtoPath = path.join(generatedTypesDir, 'RecordEngagementInputDTO.ts');
|
||||
const engagementDtoExists = await fs.access(engagementDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (engagementDtoExists) {
|
||||
const engagementDtoContent = await fs.readFile(engagementDtoPath, 'utf-8');
|
||||
|
||||
// Check that all properties are present
|
||||
if (engagementSchema.properties) {
|
||||
for (const propName of Object.keys(engagementSchema.properties)) {
|
||||
expect(engagementDtoContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetAnalyticsMetricsOutputDTO
|
||||
const metricsSchema = schemas['GetAnalyticsMetricsOutputDTO'];
|
||||
const metricsDtoPath = path.join(generatedTypesDir, 'GetAnalyticsMetricsOutputDTO.ts');
|
||||
const metricsDtoExists = await fs.access(metricsDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (metricsDtoExists) {
|
||||
const metricsDtoContent = await fs.readFile(metricsDtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (metricsSchema.required) {
|
||||
for (const requiredProp of metricsSchema.required) {
|
||||
expect(metricsDtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test GetDashboardDataOutputDTO
|
||||
const dashboardSchema = schemas['GetDashboardDataOutputDTO'];
|
||||
const dashboardDtoPath = path.join(generatedTypesDir, 'GetDashboardDataOutputDTO.ts');
|
||||
const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (dashboardDtoExists) {
|
||||
const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (dashboardSchema.required) {
|
||||
for (const requiredProp of dashboardSchema.required) {
|
||||
expect(dashboardDtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have analytics types defined in tbd folder', async () => {
|
||||
// Check if analytics types exist in tbd folder (similar to admin types)
|
||||
const tbdDir = path.join(websiteTypesDir, 'tbd');
|
||||
const tbdFiles = await fs.readdir(tbdDir).catch(() => []);
|
||||
|
||||
// Analytics types might be in a separate file or combined with existing types
|
||||
// For now, we'll check if the generated types are properly available
|
||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||
const analyticsGenerated = generatedFiles.filter(f =>
|
||||
f.includes('Analytics') ||
|
||||
f.includes('Record') ||
|
||||
f.includes('PageView') ||
|
||||
f.includes('Engagement')
|
||||
);
|
||||
|
||||
expect(analyticsGenerated.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('should have analytics types re-exported from main types file', async () => {
|
||||
// Check if there's an analytics.ts file or if types are exported elsewhere
|
||||
const analyticsTypesPath = path.join(websiteTypesDir, 'analytics.ts');
|
||||
const analyticsTypesExists = await fs.access(analyticsTypesPath).then(() => true).catch(() => false);
|
||||
|
||||
if (analyticsTypesExists) {
|
||||
const analyticsTypesContent = await fs.readFile(analyticsTypesPath, 'utf-8');
|
||||
|
||||
// Verify re-exports
|
||||
expect(analyticsTypesContent).toContain('RecordPageViewInputDTO');
|
||||
expect(analyticsTypesContent).toContain('RecordEngagementInputDTO');
|
||||
expect(analyticsTypesContent).toContain('GetAnalyticsMetricsOutputDTO');
|
||||
expect(analyticsTypesContent).toContain('GetDashboardDataOutputDTO');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics API Client Contract', () => {
|
||||
it('should have AnalyticsApiClient defined', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientExists = await fs.access(analyticsApiClientPath).then(() => true).catch(() => false);
|
||||
|
||||
expect(analyticsApiClientExists).toBe(true);
|
||||
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify class definition
|
||||
expect(analyticsApiClientContent).toContain('export class AnalyticsApiClient');
|
||||
expect(analyticsApiClientContent).toContain('extends BaseApiClient');
|
||||
|
||||
// Verify methods exist
|
||||
expect(analyticsApiClientContent).toContain('recordPageView');
|
||||
expect(analyticsApiClientContent).toContain('recordEngagement');
|
||||
expect(analyticsApiClientContent).toContain('getDashboardData');
|
||||
expect(analyticsApiClientContent).toContain('getAnalyticsMetrics');
|
||||
|
||||
// Verify method signatures
|
||||
expect(analyticsApiClientContent).toContain('recordPageView(input: RecordPageViewInputDTO)');
|
||||
expect(analyticsApiClientContent).toContain('recordEngagement(input: RecordEngagementInputDTO)');
|
||||
expect(analyticsApiClientContent).toContain('getDashboardData()');
|
||||
expect(analyticsApiClientContent).toContain('getAnalyticsMetrics()');
|
||||
});
|
||||
|
||||
it('should have proper request construction in recordPageView method', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify POST request with input
|
||||
expect(analyticsApiClientContent).toContain("return this.post<RecordPageViewOutputDTO>('/analytics/page-view', input)");
|
||||
});
|
||||
|
||||
it('should have proper request construction in recordEngagement method', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify POST request with input
|
||||
expect(analyticsApiClientContent).toContain("return this.post<RecordEngagementOutputDTO>('/analytics/engagement', input)");
|
||||
});
|
||||
|
||||
it('should have proper request construction in getDashboardData method', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify GET request
|
||||
expect(analyticsApiClientContent).toContain("return this.get<GetDashboardDataOutputDTO>('/analytics/dashboard')");
|
||||
});
|
||||
|
||||
it('should have proper request construction in getAnalyticsMetrics method', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify GET request
|
||||
expect(analyticsApiClientContent).toContain("return this.get<GetAnalyticsMetricsOutputDTO>('/analytics/metrics')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Correctness Tests', () => {
|
||||
it('should validate RecordPageViewInputDTO required fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
|
||||
// Verify all required fields are present
|
||||
expect(schema.required).toContain('entityType');
|
||||
expect(schema.required).toContain('entityId');
|
||||
expect(schema.required).toContain('visitorType');
|
||||
expect(schema.required).toContain('sessionId');
|
||||
|
||||
// Verify no extra required fields
|
||||
expect(schema.required.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should validate RecordPageViewInputDTO optional fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
|
||||
// Verify optional fields are not required
|
||||
expect(schema.required).not.toContain('visitorId');
|
||||
expect(schema.required).not.toContain('referrer');
|
||||
expect(schema.required).not.toContain('userAgent');
|
||||
expect(schema.required).not.toContain('country');
|
||||
|
||||
// Verify optional fields exist
|
||||
expect(schema.properties?.visitorId).toBeDefined();
|
||||
expect(schema.properties?.referrer).toBeDefined();
|
||||
expect(schema.properties?.userAgent).toBeDefined();
|
||||
expect(schema.properties?.country).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate RecordEngagementInputDTO required fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
|
||||
// Verify all required fields are present
|
||||
expect(schema.required).toContain('action');
|
||||
expect(schema.required).toContain('entityType');
|
||||
expect(schema.required).toContain('entityId');
|
||||
expect(schema.required).toContain('actorType');
|
||||
expect(schema.required).toContain('sessionId');
|
||||
|
||||
// Verify no extra required fields
|
||||
expect(schema.required.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should validate RecordEngagementInputDTO optional fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
|
||||
// Verify optional fields are not required
|
||||
expect(schema.required).not.toContain('actorId');
|
||||
expect(schema.required).not.toContain('metadata');
|
||||
|
||||
// Verify optional fields exist
|
||||
expect(schema.properties?.actorId).toBeDefined();
|
||||
expect(schema.properties?.metadata).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate GetAnalyticsMetricsOutputDTO structure', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
|
||||
|
||||
// Verify all required fields
|
||||
expect(schema.required).toContain('pageViews');
|
||||
expect(schema.required).toContain('uniqueVisitors');
|
||||
expect(schema.required).toContain('averageSessionDuration');
|
||||
expect(schema.required).toContain('bounceRate');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.pageViews?.type).toBe('number');
|
||||
expect(schema.properties?.uniqueVisitors?.type).toBe('number');
|
||||
expect(schema.properties?.averageSessionDuration?.type).toBe('number');
|
||||
expect(schema.properties?.bounceRate?.type).toBe('number');
|
||||
});
|
||||
|
||||
it('should validate GetDashboardDataOutputDTO structure', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['GetDashboardDataOutputDTO'];
|
||||
|
||||
// Verify all required fields
|
||||
expect(schema.required).toContain('totalUsers');
|
||||
expect(schema.required).toContain('activeUsers');
|
||||
expect(schema.required).toContain('totalRaces');
|
||||
expect(schema.required).toContain('totalLeagues');
|
||||
|
||||
// Verify field types
|
||||
expect(schema.properties?.totalUsers?.type).toBe('number');
|
||||
expect(schema.properties?.activeUsers?.type).toBe('number');
|
||||
expect(schema.properties?.totalRaces?.type).toBe('number');
|
||||
expect(schema.properties?.totalLeagues?.type).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Handling Tests', () => {
|
||||
it('should handle successful page view recording response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pageViewSchema = spec.components.schemas['RecordPageViewOutputDTO'];
|
||||
|
||||
// Verify response structure
|
||||
expect(pageViewSchema.properties?.pageViewId).toBeDefined();
|
||||
expect(pageViewSchema.properties?.pageViewId?.type).toBe('string');
|
||||
expect(pageViewSchema.required).toContain('pageViewId');
|
||||
});
|
||||
|
||||
it('should handle successful engagement recording response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const engagementSchema = spec.components.schemas['RecordEngagementOutputDTO'];
|
||||
|
||||
// Verify response structure
|
||||
expect(engagementSchema.properties?.eventId).toBeDefined();
|
||||
expect(engagementSchema.properties?.engagementWeight).toBeDefined();
|
||||
expect(engagementSchema.properties?.eventId?.type).toBe('string');
|
||||
expect(engagementSchema.properties?.engagementWeight?.type).toBe('number');
|
||||
expect(engagementSchema.required).toContain('eventId');
|
||||
expect(engagementSchema.required).toContain('engagementWeight');
|
||||
});
|
||||
|
||||
it('should handle metrics response with all required fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const metricsSchema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
|
||||
|
||||
// Verify all required fields are present
|
||||
for (const field of ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate']) {
|
||||
expect(metricsSchema.required).toContain(field);
|
||||
expect(metricsSchema.properties?.[field]).toBeDefined();
|
||||
expect(metricsSchema.properties?.[field]?.type).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle dashboard data response with all required fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const dashboardSchema = spec.components.schemas['GetDashboardDataOutputDTO'];
|
||||
|
||||
// Verify all required fields are present
|
||||
for (const field of ['totalUsers', 'activeUsers', 'totalRaces', 'totalLeagues']) {
|
||||
expect(dashboardSchema.required).toContain(field);
|
||||
expect(dashboardSchema.properties?.[field]).toBeDefined();
|
||||
expect(dashboardSchema.properties?.[field]?.type).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle optional fields in page view input correctly', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
|
||||
// Verify optional fields are nullable or optional
|
||||
expect(schema.properties?.visitorId?.type).toBe('string');
|
||||
expect(schema.properties?.referrer?.type).toBe('string');
|
||||
expect(schema.properties?.userAgent?.type).toBe('string');
|
||||
expect(schema.properties?.country?.type).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle optional fields in engagement input correctly', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const schema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
|
||||
// Verify optional fields
|
||||
expect(schema.properties?.actorId?.type).toBe('string');
|
||||
expect(schema.properties?.metadata?.type).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling Tests', () => {
|
||||
it('should document 400 Bad Request response for invalid page view input', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pageViewPath = spec.paths['/analytics/page-view']?.post;
|
||||
|
||||
// Check if 400 response is documented
|
||||
if (pageViewPath.responses['400']) {
|
||||
expect(pageViewPath.responses['400']).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should document 400 Bad Request response for invalid engagement input', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const engagementPath = spec.paths['/analytics/engagement']?.post;
|
||||
|
||||
// Check if 400 response is documented
|
||||
if (engagementPath.responses['400']) {
|
||||
expect(engagementPath.responses['400']).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should document 401 Unauthorized response for protected endpoints', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Dashboard and metrics endpoints should require authentication
|
||||
const dashboardPath = spec.paths['/analytics/dashboard']?.get;
|
||||
const metricsPath = spec.paths['/analytics/metrics']?.get;
|
||||
|
||||
// Check if 401 responses are documented
|
||||
if (dashboardPath.responses['401']) {
|
||||
expect(dashboardPath.responses['401']).toBeDefined();
|
||||
}
|
||||
if (metricsPath.responses['401']) {
|
||||
expect(metricsPath.responses['401']).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should document 500 Internal Server Error response', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pageViewPath = spec.paths['/analytics/page-view']?.post;
|
||||
const engagementPath = spec.paths['/analytics/engagement']?.post;
|
||||
|
||||
// Check if 500 response is documented for recording endpoints
|
||||
if (pageViewPath.responses['500']) {
|
||||
expect(pageViewPath.responses['500']).toBeDefined();
|
||||
}
|
||||
if (engagementPath.responses['500']) {
|
||||
expect(engagementPath.responses['500']).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should have proper error handling in AnalyticsApiClient', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify BaseApiClient is extended (which provides error handling)
|
||||
expect(analyticsApiClientContent).toContain('extends BaseApiClient');
|
||||
|
||||
// Verify methods use BaseApiClient methods (which handle errors)
|
||||
expect(analyticsApiClientContent).toContain('this.post<');
|
||||
expect(analyticsApiClientContent).toContain('this.get<');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Semantic Guarantee Tests', () => {
|
||||
it('should maintain consistency between request and response schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Verify page view request/response consistency
|
||||
const pageViewInputSchema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
const pageViewOutputSchema = spec.components.schemas['RecordPageViewOutputDTO'];
|
||||
|
||||
// Output should contain a reference to the input (pageViewId relates to the recorded page view)
|
||||
expect(pageViewOutputSchema.properties?.pageViewId).toBeDefined();
|
||||
expect(pageViewOutputSchema.properties?.pageViewId?.type).toBe('string');
|
||||
|
||||
// Verify engagement request/response consistency
|
||||
const engagementInputSchema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
const engagementOutputSchema = spec.components.schemas['RecordEngagementOutputDTO'];
|
||||
|
||||
// Output should contain event reference and engagement weight
|
||||
expect(engagementOutputSchema.properties?.eventId).toBeDefined();
|
||||
expect(engagementOutputSchema.properties?.engagementWeight).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate semantic consistency in analytics metrics', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const metricsSchema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
|
||||
|
||||
// Verify metrics are non-negative numbers
|
||||
expect(metricsSchema.properties?.pageViews?.type).toBe('number');
|
||||
expect(metricsSchema.properties?.uniqueVisitors?.type).toBe('number');
|
||||
expect(metricsSchema.properties?.averageSessionDuration?.type).toBe('number');
|
||||
expect(metricsSchema.properties?.bounceRate?.type).toBe('number');
|
||||
|
||||
// Verify bounce rate is a percentage (0-1 range or 0-100)
|
||||
// This is a semantic guarantee that should be documented
|
||||
expect(metricsSchema.properties?.bounceRate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate semantic consistency in dashboard data', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const dashboardSchema = spec.components.schemas['GetDashboardDataOutputDTO'];
|
||||
|
||||
// Verify dashboard metrics are non-negative numbers
|
||||
expect(dashboardSchema.properties?.totalUsers?.type).toBe('number');
|
||||
expect(dashboardSchema.properties?.activeUsers?.type).toBe('number');
|
||||
expect(dashboardSchema.properties?.totalRaces?.type).toBe('number');
|
||||
expect(dashboardSchema.properties?.totalLeagues?.type).toBe('number');
|
||||
|
||||
// Semantic guarantee: activeUsers <= totalUsers
|
||||
// This should be enforced by the backend
|
||||
expect(dashboardSchema.properties?.activeUsers).toBeDefined();
|
||||
expect(dashboardSchema.properties?.totalUsers).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate idempotency for analytics recording', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check if recording endpoints support idempotency
|
||||
const pageViewPath = spec.paths['/analytics/page-view']?.post;
|
||||
const engagementPath = spec.paths['/analytics/engagement']?.post;
|
||||
|
||||
// Verify session-based deduplication is possible
|
||||
const pageViewSchema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
const engagementSchema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
|
||||
// Both should have sessionId for deduplication
|
||||
expect(pageViewSchema.properties?.sessionId).toBeDefined();
|
||||
expect(engagementSchema.properties?.sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate uniqueness constraints for analytics entities', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pageViewSchema = spec.components.schemas['RecordPageViewInputDTO'];
|
||||
const engagementSchema = spec.components.schemas['RecordEngagementInputDTO'];
|
||||
|
||||
// Verify entity identification fields are required
|
||||
expect(pageViewSchema.required).toContain('entityType');
|
||||
expect(pageViewSchema.required).toContain('entityId');
|
||||
expect(pageViewSchema.required).toContain('sessionId');
|
||||
|
||||
expect(engagementSchema.required).toContain('entityType');
|
||||
expect(engagementSchema.required).toContain('entityId');
|
||||
expect(engagementSchema.required).toContain('sessionId');
|
||||
});
|
||||
|
||||
it('should validate consistency between request and response types', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Verify all DTOs have consistent type definitions
|
||||
const dtos = [
|
||||
'RecordPageViewInputDTO',
|
||||
'RecordPageViewOutputDTO',
|
||||
'RecordEngagementInputDTO',
|
||||
'RecordEngagementOutputDTO',
|
||||
'GetAnalyticsMetricsOutputDTO',
|
||||
'GetDashboardDataOutputDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of dtos) {
|
||||
const schema = spec.components.schemas[dtoName];
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema.type).toBe('object');
|
||||
|
||||
// All should have properties defined
|
||||
expect(schema.properties).toBeDefined();
|
||||
|
||||
// All should have required fields (even if empty array)
|
||||
expect(schema.required).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Analytics Module Integration Tests', () => {
|
||||
it('should have consistent types between API DTOs and website types', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||
const generatedDTOs = generatedFiles
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => f.replace('.ts', ''));
|
||||
|
||||
// Check all analytics DTOs exist in generated types
|
||||
const analyticsDTOs = [
|
||||
'RecordPageViewInputDTO',
|
||||
'RecordPageViewOutputDTO',
|
||||
'RecordEngagementInputDTO',
|
||||
'RecordEngagementOutputDTO',
|
||||
'GetAnalyticsMetricsOutputDTO',
|
||||
'GetDashboardDataOutputDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of analyticsDTOs) {
|
||||
expect(spec.components.schemas[dtoName]).toBeDefined();
|
||||
expect(generatedDTOs).toContain(dtoName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have AnalyticsApiClient methods matching API endpoints', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify recordPageView method exists and uses correct endpoint
|
||||
expect(analyticsApiClientContent).toContain('async recordPageView');
|
||||
expect(analyticsApiClientContent).toContain("return this.post<RecordPageViewOutputDTO>('/analytics/page-view', input)");
|
||||
|
||||
// Verify recordEngagement method exists and uses correct endpoint
|
||||
expect(analyticsApiClientContent).toContain('async recordEngagement');
|
||||
expect(analyticsApiClientContent).toContain("return this.post<RecordEngagementOutputDTO>('/analytics/engagement', input)");
|
||||
|
||||
// Verify getDashboardData method exists and uses correct endpoint
|
||||
expect(analyticsApiClientContent).toContain('async getDashboardData');
|
||||
expect(analyticsApiClientContent).toContain("return this.get<GetDashboardDataOutputDTO>('/analytics/dashboard')");
|
||||
|
||||
// Verify getAnalyticsMetrics method exists and uses correct endpoint
|
||||
expect(analyticsApiClientContent).toContain('async getAnalyticsMetrics');
|
||||
expect(analyticsApiClientContent).toContain("return this.get<GetAnalyticsMetricsOutputDTO>('/analytics/metrics')");
|
||||
});
|
||||
|
||||
it('should have proper error handling in AnalyticsApiClient', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify BaseApiClient is extended (which provides error handling)
|
||||
expect(analyticsApiClientContent).toContain('extends BaseApiClient');
|
||||
|
||||
// Verify methods use BaseApiClient methods (which handle errors)
|
||||
expect(analyticsApiClientContent).toContain('this.post<');
|
||||
expect(analyticsApiClientContent).toContain('this.get<');
|
||||
});
|
||||
|
||||
it('should have consistent type imports in AnalyticsApiClient', async () => {
|
||||
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
|
||||
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
|
||||
|
||||
// Verify all required types are imported
|
||||
expect(analyticsApiClientContent).toContain('RecordPageViewOutputDTO');
|
||||
expect(analyticsApiClientContent).toContain('RecordEngagementOutputDTO');
|
||||
expect(analyticsApiClientContent).toContain('GetDashboardDataOutputDTO');
|
||||
expect(analyticsApiClientContent).toContain('GetAnalyticsMetricsOutputDTO');
|
||||
expect(analyticsApiClientContent).toContain('RecordPageViewInputDTO');
|
||||
expect(analyticsApiClientContent).toContain('RecordEngagementInputDTO');
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* Contract Validation Tests for Bootstrap Module
|
||||
*
|
||||
* These tests validate that the bootstrap module is properly configured and that
|
||||
* the initialization process follows expected patterns. The bootstrap module is
|
||||
* an internal initialization module that runs during application startup and
|
||||
* does not expose HTTP endpoints.
|
||||
*
|
||||
* Key Findings:
|
||||
* - Bootstrap module is an internal initialization module (not an API endpoint)
|
||||
* - It runs during application startup via OnModuleInit lifecycle hook
|
||||
* - It seeds the database with initial data (admin users, achievements, racing data)
|
||||
* - It does not expose any HTTP controllers or endpoints
|
||||
* - No API client exists in the website app for bootstrap operations
|
||||
* - No bootstrap-related endpoints are defined in the OpenAPI spec
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
interface OpenAPISpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
description: string;
|
||||
version: string;
|
||||
};
|
||||
paths: Record<string, any>;
|
||||
components: {
|
||||
schemas: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('Bootstrap Module Contract Validation', () => {
|
||||
const apiRoot = path.join(__dirname, '../..');
|
||||
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
|
||||
const bootstrapModulePath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.ts');
|
||||
const bootstrapAdaptersPath = path.join(apiRoot, 'adapters/bootstrap');
|
||||
|
||||
describe('OpenAPI Spec Integrity for Bootstrap', () => {
|
||||
it('should NOT have bootstrap endpoints defined in OpenAPI spec', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Bootstrap is an internal module, not an API endpoint
|
||||
// Verify no bootstrap-related paths exist
|
||||
const bootstrapPaths = Object.keys(spec.paths).filter(p => p.includes('bootstrap'));
|
||||
expect(bootstrapPaths.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should NOT have bootstrap-related DTOs in OpenAPI spec', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Bootstrap module doesn't expose DTOs for API consumption
|
||||
// It uses internal DTOs for seeding data
|
||||
const bootstrapSchemas = Object.keys(spec.components.schemas).filter(s =>
|
||||
s.toLowerCase().includes('bootstrap') ||
|
||||
s.toLowerCase().includes('seed')
|
||||
);
|
||||
expect(bootstrapSchemas.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Module Structure', () => {
|
||||
it('should have BootstrapModule defined', async () => {
|
||||
const bootstrapModuleExists = await fs.access(bootstrapModulePath).then(() => true).catch(() => false);
|
||||
expect(bootstrapModuleExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have BootstrapModule implement OnModuleInit', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify it implements OnModuleInit lifecycle hook
|
||||
expect(bootstrapModuleContent).toContain('implements OnModuleInit');
|
||||
expect(bootstrapModuleContent).toContain('async onModuleInit()');
|
||||
});
|
||||
|
||||
it('should have BootstrapModule with proper dependencies', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify required dependencies are injected
|
||||
expect(bootstrapModuleContent).toContain('@Inject(ENSURE_INITIAL_DATA_TOKEN)');
|
||||
expect(bootstrapModuleContent).toContain('@Inject(SEED_DEMO_USERS_TOKEN)');
|
||||
expect(bootstrapModuleContent).toContain('@Inject(\'Logger\')');
|
||||
expect(bootstrapModuleContent).toContain('@Inject(\'RacingSeedDependencies\')');
|
||||
});
|
||||
|
||||
it('should have BootstrapModule with proper imports', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify persistence modules are imported
|
||||
expect(bootstrapModuleContent).toContain('RacingPersistenceModule');
|
||||
expect(bootstrapModuleContent).toContain('SocialPersistenceModule');
|
||||
expect(bootstrapModuleContent).toContain('AchievementPersistenceModule');
|
||||
expect(bootstrapModuleContent).toContain('IdentityPersistenceModule');
|
||||
expect(bootstrapModuleContent).toContain('AdminPersistenceModule');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Adapters Structure', () => {
|
||||
it('should have EnsureInitialData adapter', async () => {
|
||||
const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts');
|
||||
const ensureInitialDataExists = await fs.access(ensureInitialDataPath).then(() => true).catch(() => false);
|
||||
expect(ensureInitialDataExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have SeedDemoUsers adapter', async () => {
|
||||
const seedDemoUsersPath = path.join(bootstrapAdaptersPath, 'SeedDemoUsers.ts');
|
||||
const seedDemoUsersExists = await fs.access(seedDemoUsersPath).then(() => true).catch(() => false);
|
||||
expect(seedDemoUsersExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have SeedRacingData adapter', async () => {
|
||||
const seedRacingDataPath = path.join(bootstrapAdaptersPath, 'SeedRacingData.ts');
|
||||
const seedRacingDataExists = await fs.access(seedRacingDataPath).then(() => true).catch(() => false);
|
||||
expect(seedRacingDataExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have racing seed factories', async () => {
|
||||
const racingDir = path.join(bootstrapAdaptersPath, 'racing');
|
||||
const racingDirExists = await fs.access(racingDir).then(() => true).catch(() => false);
|
||||
expect(racingDirExists).toBe(true);
|
||||
|
||||
// Verify key factory files exist
|
||||
const racingFiles = await fs.readdir(racingDir);
|
||||
expect(racingFiles).toContain('RacingDriverFactory.ts');
|
||||
expect(racingFiles).toContain('RacingTeamFactory.ts');
|
||||
expect(racingFiles).toContain('RacingLeagueFactory.ts');
|
||||
expect(racingFiles).toContain('RacingRaceFactory.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Configuration', () => {
|
||||
it('should have bootstrap configuration in environment', async () => {
|
||||
const envPath = path.join(apiRoot, 'apps/api/src/env.ts');
|
||||
const envContent = await fs.readFile(envPath, 'utf-8');
|
||||
|
||||
// Verify bootstrap configuration functions exist
|
||||
expect(envContent).toContain('getEnableBootstrap');
|
||||
expect(envContent).toContain('getForceReseed');
|
||||
});
|
||||
|
||||
it('should have bootstrap enabled by default', async () => {
|
||||
const envPath = path.join(apiRoot, 'apps/api/src/env.ts');
|
||||
const envContent = await fs.readFile(envPath, 'utf-8');
|
||||
|
||||
// Verify bootstrap is enabled by default (for dev/test)
|
||||
expect(envContent).toContain('GRIDPILOT_API_BOOTSTRAP');
|
||||
expect(envContent).toContain('true'); // Default value
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Initialization Logic', () => {
|
||||
it('should have proper initialization sequence', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify initialization sequence
|
||||
expect(bootstrapModuleContent).toContain('await this.ensureInitialData.execute()');
|
||||
expect(bootstrapModuleContent).toContain('await this.shouldSeedRacingData()');
|
||||
expect(bootstrapModuleContent).toContain('await this.shouldSeedDemoUsers()');
|
||||
});
|
||||
|
||||
it('should have environment-aware seeding logic', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify environment checks
|
||||
expect(bootstrapModuleContent).toContain('process.env.NODE_ENV');
|
||||
expect(bootstrapModuleContent).toContain('production');
|
||||
expect(bootstrapModuleContent).toContain('inmemory');
|
||||
expect(bootstrapModuleContent).toContain('postgres');
|
||||
});
|
||||
|
||||
it('should have force reseed capability', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify force reseed logic
|
||||
expect(bootstrapModuleContent).toContain('getForceReseed()');
|
||||
expect(bootstrapModuleContent).toContain('Force reseed enabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Data Seeding', () => {
|
||||
it('should seed initial admin user', async () => {
|
||||
const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts');
|
||||
const ensureInitialDataContent = await fs.readFile(ensureInitialDataPath, 'utf-8');
|
||||
|
||||
// Verify admin user seeding
|
||||
expect(ensureInitialDataContent).toContain('admin@gridpilot.local');
|
||||
expect(ensureInitialDataContent).toContain('Admin');
|
||||
expect(ensureInitialDataContent).toContain('signupUseCase');
|
||||
});
|
||||
|
||||
it('should seed achievements', async () => {
|
||||
const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts');
|
||||
const ensureInitialDataContent = await fs.readFile(ensureInitialDataPath, 'utf-8');
|
||||
|
||||
// Verify achievement seeding
|
||||
expect(ensureInitialDataContent).toContain('DRIVER_ACHIEVEMENTS');
|
||||
expect(ensureInitialDataContent).toContain('STEWARD_ACHIEVEMENTS');
|
||||
expect(ensureInitialDataContent).toContain('ADMIN_ACHIEVEMENTS');
|
||||
expect(ensureInitialDataContent).toContain('COMMUNITY_ACHIEVEMENTS');
|
||||
expect(ensureInitialDataContent).toContain('createAchievementUseCase');
|
||||
});
|
||||
|
||||
it('should seed demo users', async () => {
|
||||
const seedDemoUsersPath = path.join(bootstrapAdaptersPath, 'SeedDemoUsers.ts');
|
||||
const seedDemoUsersContent = await fs.readFile(seedDemoUsersPath, 'utf-8');
|
||||
|
||||
// Verify demo user seeding
|
||||
expect(seedDemoUsersContent).toContain('SeedDemoUsers');
|
||||
expect(seedDemoUsersContent).toContain('execute');
|
||||
});
|
||||
|
||||
it('should seed racing data', async () => {
|
||||
const seedRacingDataPath = path.join(bootstrapAdaptersPath, 'SeedRacingData.ts');
|
||||
const seedRacingDataContent = await fs.readFile(seedRacingDataPath, 'utf-8');
|
||||
|
||||
// Verify racing data seeding
|
||||
expect(seedRacingDataContent).toContain('SeedRacingData');
|
||||
expect(seedRacingDataContent).toContain('execute');
|
||||
expect(seedRacingDataContent).toContain('RacingSeedDependencies');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Providers', () => {
|
||||
it('should have BootstrapProviders defined', async () => {
|
||||
const bootstrapProvidersPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapProviders.ts');
|
||||
const bootstrapProvidersExists = await fs.access(bootstrapProvidersPath).then(() => true).catch(() => false);
|
||||
expect(bootstrapProvidersExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have proper provider tokens', async () => {
|
||||
const bootstrapProvidersContent = await fs.readFile(
|
||||
path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapProviders.ts'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// Verify provider tokens are defined
|
||||
expect(bootstrapProvidersContent).toContain('ENSURE_INITIAL_DATA_TOKEN');
|
||||
expect(bootstrapProvidersContent).toContain('SEED_DEMO_USERS_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Module Integration', () => {
|
||||
it('should be imported in main app module', async () => {
|
||||
const appModulePath = path.join(apiRoot, 'apps/api/src/app.module.ts');
|
||||
const appModuleContent = await fs.readFile(appModulePath, 'utf-8');
|
||||
|
||||
// Verify BootstrapModule is imported
|
||||
expect(appModuleContent).toContain('BootstrapModule');
|
||||
expect(appModuleContent).toContain('./domain/bootstrap/BootstrapModule');
|
||||
});
|
||||
|
||||
it('should be included in app module imports', async () => {
|
||||
const appModulePath = path.join(apiRoot, 'apps/api/src/app.module.ts');
|
||||
const appModuleContent = await fs.readFile(appModulePath, 'utf-8');
|
||||
|
||||
// Verify BootstrapModule is in imports array
|
||||
expect(appModuleContent).toMatch(/imports:\s*\[[^\]]*BootstrapModule[^\]]*\]/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Module Tests', () => {
|
||||
it('should have unit tests for BootstrapModule', async () => {
|
||||
const bootstrapModuleTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.test.ts');
|
||||
const bootstrapModuleTestExists = await fs.access(bootstrapModuleTestPath).then(() => true).catch(() => false);
|
||||
expect(bootstrapModuleTestExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have postgres seed tests', async () => {
|
||||
const postgresSeedTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.postgres-seed.test.ts');
|
||||
const postgresSeedTestExists = await fs.access(postgresSeedTestPath).then(() => true).catch(() => false);
|
||||
expect(postgresSeedTestExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have racing seed tests', async () => {
|
||||
const racingSeedTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/RacingSeed.test.ts');
|
||||
const racingSeedTestExists = await fs.access(racingSeedTestPath).then(() => true).catch(() => false);
|
||||
expect(racingSeedTestExists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bootstrap Module Contract Summary', () => {
|
||||
it('should document that bootstrap is an internal module', async () => {
|
||||
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
|
||||
|
||||
// Verify bootstrap is documented as internal initialization
|
||||
expect(bootstrapModuleContent).toContain('Initializing application data');
|
||||
expect(bootstrapModuleContent).toContain('Bootstrap disabled');
|
||||
});
|
||||
|
||||
it('should have no API client in website app', async () => {
|
||||
const websiteApiDir = path.join(apiRoot, 'apps/website/lib/api');
|
||||
const apiFiles = await fs.readdir(websiteApiDir);
|
||||
|
||||
// Verify no bootstrap API client exists
|
||||
const bootstrapFiles = apiFiles.filter(f => f.toLowerCase().includes('bootstrap'));
|
||||
expect(bootstrapFiles.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should have no bootstrap endpoints in OpenAPI', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Verify no bootstrap paths exist
|
||||
const allPaths = Object.keys(spec.paths);
|
||||
const bootstrapPaths = allPaths.filter(p => p.toLowerCase().includes('bootstrap'));
|
||||
expect(bootstrapPaths.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user