contract testing
This commit is contained in:
110
.github/workflows/contract-testing.yml
vendored
Normal file
110
.github/workflows/contract-testing.yml
vendored
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"price": {
|
"price": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"currency": {
|
"currency": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -213,28 +213,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"impressions": {
|
"impressions": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"impressionsChange": {
|
"impressionsChange": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"uniqueViewers": {
|
"uniqueViewers": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"viewersChange": {
|
"viewersChange": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"races": {
|
"races": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"drivers": {
|
"drivers": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"exposure": {
|
"exposure": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"exposureChange": {
|
"exposureChange": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -252,13 +252,13 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"activeSponsorships": {
|
"activeSponsorships": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"totalInvestment": {
|
"totalInvestment": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"costPerThousandViews": {
|
"costPerThousandViews": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -334,28 +334,32 @@
|
|||||||
},
|
},
|
||||||
"date": {
|
"date": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name",
|
||||||
"date"
|
"date",
|
||||||
|
"views"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"PrivacySettingsDTO": {
|
"PrivacySettingsDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"publicProfile": {
|
"publicProfile": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"showStats": {
|
"showStats": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"showActiveSponsorships": {
|
"showActiveSponsorships": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"allowDirectContact": {
|
"allowDirectContact": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -380,22 +384,22 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"emailNewSponsorships": {
|
"emailNewSponsorships": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"emailWeeklyReport": {
|
"emailWeeklyReport": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"emailRaceAlerts": {
|
"emailRaceAlerts": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"emailPaymentAlerts": {
|
"emailPaymentAlerts": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"emailNewOpportunities": {
|
"emailNewOpportunities": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"emailContractExpiry": {
|
"emailContractExpiry": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -442,13 +446,13 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"amount": {
|
"amount": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"vatAmount": {
|
"vatAmount": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"totalAmount": {
|
"totalAmount": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -519,21 +523,33 @@
|
|||||||
"id": {
|
"id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"iracingId": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"country": {
|
"country": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"position": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"races": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"impressions": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"iracingId",
|
|
||||||
"name",
|
"name",
|
||||||
"country"
|
"country",
|
||||||
|
"position",
|
||||||
|
"races",
|
||||||
|
"impressions",
|
||||||
|
"team"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"CreateSponsorInputDTO": {
|
"CreateSponsorInputDTO": {
|
||||||
@@ -555,22 +571,22 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"totalSpent": {
|
"totalSpent": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"pendingAmount": {
|
"pendingAmount": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"nextPaymentDate": {
|
"nextPaymentDate": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"nextPaymentAmount": {
|
"nextPaymentAmount": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"activeSponsorships": {
|
"activeSponsorships": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"averageMonthlySpend": {
|
"averageMonthlySpend": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -595,10 +611,10 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"drivers": {
|
"drivers": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"avgViewsPerRace": {
|
"avgViewsPerRace": {
|
||||||
"type": "number"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1197,12 +1213,6 @@
|
|||||||
},
|
},
|
||||||
"scheduledAt": {
|
"scheduledAt": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"isMyLeague": {
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1211,9 +1221,7 @@
|
|||||||
"leagueName",
|
"leagueName",
|
||||||
"track",
|
"track",
|
||||||
"car",
|
"car",
|
||||||
"scheduledAt",
|
"scheduledAt"
|
||||||
"status",
|
|
||||||
"isMyLeague"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"DashboardLeagueStandingSummaryDTO": {
|
"DashboardLeagueStandingSummaryDTO": {
|
||||||
@@ -1373,6 +1381,36 @@
|
|||||||
"leagueName"
|
"leagueName"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"AllRacesStatusFilterDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"value",
|
||||||
|
"label"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AllRacesLeagueFilterDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UpdatePaymentStatusInputDTO": {
|
"UpdatePaymentStatusInputDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1384,7 +1422,7 @@
|
|||||||
"paymentId"
|
"paymentId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"PaymentDto": {
|
"PaymentDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1395,7 +1433,7 @@
|
|||||||
"id"
|
"id"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"MembershipFeeDto": {
|
"MembershipFeeDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1410,7 +1448,7 @@
|
|||||||
"leagueId"
|
"leagueId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"MemberPaymentDto": {
|
"MemberPaymentDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1441,7 +1479,7 @@
|
|||||||
"netAmount"
|
"netAmount"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"PrizeDto": {
|
"PrizeDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1472,7 +1510,7 @@
|
|||||||
"amount"
|
"amount"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"WalletDto": {
|
"WalletDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1512,7 +1550,7 @@
|
|||||||
"currency"
|
"currency"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"TransactionDto": {
|
"TransactionDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1527,18 +1565,7 @@
|
|||||||
"walletId"
|
"walletId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"PaymentDTO": {
|
"DeletePrizeResultDTO": {
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"UploadMediaOutputDTO": {
|
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"success": {
|
"success": {
|
||||||
@@ -1549,11 +1576,22 @@
|
|||||||
"success"
|
"success"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"UploadMediaOutputDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"success"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UpdateAvatarOutputDTO": {
|
"UpdateAvatarOutputDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"success": {
|
"success": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1610,8 +1648,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"uploadedAt": {
|
"uploadedAt": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"format": "date-time"
|
|
||||||
},
|
},
|
||||||
"size": {
|
"size": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
@@ -1639,7 +1676,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"success": {
|
"success": {
|
||||||
"type": "boolean"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1840,33 +1877,11 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ownerId": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"maxDrivers": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"sessionDuration": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"visibility": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"name",
|
"name"
|
||||||
"description",
|
|
||||||
"ownerId",
|
|
||||||
"settings",
|
|
||||||
"maxDrivers"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LeagueSummaryDTO": {
|
"LeagueSummaryDTO": {
|
||||||
@@ -2283,151 +2298,6 @@
|
|||||||
"driverId"
|
"driverId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"DriverProfileDriverSummaryDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"country": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"avatarUrl": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"country",
|
|
||||||
"avatarUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DriverProfileStatsDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"totalRaces": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"wins": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"podiums": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"dnfs": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"totalRaces",
|
|
||||||
"wins",
|
|
||||||
"podiums",
|
|
||||||
"dnfs"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DriverProfileFinishDistributionDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"totalRaces": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"wins": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"podiums": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"topTen": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"dnfs": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"other": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"totalRaces",
|
|
||||||
"wins",
|
|
||||||
"podiums",
|
|
||||||
"topTen",
|
|
||||||
"dnfs",
|
|
||||||
"other"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DriverProfileTeamMembershipDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"teamId": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"teamName": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"teamId",
|
|
||||||
"teamName"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DriverProfileSocialFriendSummaryDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"country": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"avatarUrl": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"name",
|
|
||||||
"country",
|
|
||||||
"avatarUrl"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DriverProfileSocialSummaryDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"friendsCount": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"friendsCount"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"DriverProfileAchievementDTO": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"id",
|
|
||||||
"title",
|
|
||||||
"description"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"GetDriverOutputDTO": {
|
"GetDriverOutputDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -2488,6 +2358,151 @@
|
|||||||
"driverId"
|
"driverId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"DriverProfileTeamMembershipDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"teamId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"teamName": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"teamId",
|
||||||
|
"teamName"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DriverProfileStatsDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"totalRaces": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"wins": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"podiums": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"dnfs": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"totalRaces",
|
||||||
|
"wins",
|
||||||
|
"podiums",
|
||||||
|
"dnfs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DriverProfileSocialSummaryDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"friendsCount": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"friendsCount"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DriverProfileSocialFriendSummaryDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avatarUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"country",
|
||||||
|
"avatarUrl"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DriverProfileFinishDistributionDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"totalRaces": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"wins": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"podiums": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"topTen": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"dnfs": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"other": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"totalRaces",
|
||||||
|
"wins",
|
||||||
|
"podiums",
|
||||||
|
"topTen",
|
||||||
|
"dnfs",
|
||||||
|
"other"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DriverProfileDriverSummaryDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"avatarUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"country",
|
||||||
|
"avatarUrl"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"DriverProfileAchievementDTO": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"description"
|
||||||
|
]
|
||||||
|
},
|
||||||
"DriverLeaderboardItemDTO": {
|
"DriverLeaderboardItemDTO": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
257
apps/api/src/shared/testing/contractValidation.test.ts
Normal file
257
apps/api/src/shared/testing/contractValidation.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Contract Validation Tests for API
|
||||||
|
*
|
||||||
|
* These tests validate that the API DTOs and OpenAPI spec are consistent
|
||||||
|
* and that the generated types will be compatible with the website.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
interface OpenAPISchema {
|
||||||
|
type?: string;
|
||||||
|
format?: string;
|
||||||
|
$ref?: string;
|
||||||
|
items?: OpenAPISchema;
|
||||||
|
properties?: Record<string, OpenAPISchema>;
|
||||||
|
required?: string[];
|
||||||
|
enum?: string[];
|
||||||
|
nullable?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAPISpec {
|
||||||
|
openapi: string;
|
||||||
|
info: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
paths: Record<string, any>;
|
||||||
|
components: {
|
||||||
|
schemas: Record<string, OpenAPISchema>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('API Contract Validation', () => {
|
||||||
|
const apiRoot = path.join(__dirname, '../../..');
|
||||||
|
const openapiPath = path.join(apiRoot, 'openapi.json');
|
||||||
|
const generatedTypesDir = path.join(apiRoot, '../website/lib/types/generated');
|
||||||
|
|
||||||
|
describe('OpenAPI Spec Integrity', () => {
|
||||||
|
it('should have a valid OpenAPI spec file', async () => {
|
||||||
|
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
|
||||||
|
expect(specExists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have a valid JSON structure', async () => {
|
||||||
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
expect(() => JSON.parse(content)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have required OpenAPI fields', async () => {
|
||||||
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec: OpenAPISpec = JSON.parse(content);
|
||||||
|
|
||||||
|
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
|
||||||
|
expect(spec.info).toBeDefined();
|
||||||
|
expect(spec.info.title).toBeDefined();
|
||||||
|
expect(spec.info.version).toBeDefined();
|
||||||
|
expect(spec.components).toBeDefined();
|
||||||
|
expect(spec.components.schemas).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have no circular references in schemas', async () => {
|
||||||
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec: OpenAPISpec = JSON.parse(content);
|
||||||
|
const schemas = spec.components.schemas;
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const visiting = new Set<string>();
|
||||||
|
|
||||||
|
function detectCircular(schemaName: string): boolean {
|
||||||
|
if (visiting.has(schemaName)) return true;
|
||||||
|
if (visited.has(schemaName)) return false;
|
||||||
|
|
||||||
|
visiting.add(schemaName);
|
||||||
|
const schema = schemas[schemaName];
|
||||||
|
|
||||||
|
if (!schema) {
|
||||||
|
visiting.delete(schemaName);
|
||||||
|
visited.add(schemaName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check properties for references
|
||||||
|
if (schema.properties) {
|
||||||
|
for (const prop of Object.values(schema.properties)) {
|
||||||
|
if (prop.$ref) {
|
||||||
|
const refName = prop.$ref.split('/').pop();
|
||||||
|
if (refName && detectCircular(refName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prop.items?.$ref) {
|
||||||
|
const refName = prop.items.$ref.split('/').pop();
|
||||||
|
if (refName && detectCircular(refName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visiting.delete(schemaName);
|
||||||
|
visited.add(schemaName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const schemaName of Object.keys(schemas)) {
|
||||||
|
expect(detectCircular(schemaName)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DTO Consistency', () => {
|
||||||
|
it('should have DTO files for all schemas', async () => {
|
||||||
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec: OpenAPISpec = JSON.parse(content);
|
||||||
|
const schemas = Object.keys(spec.components.schemas);
|
||||||
|
|
||||||
|
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||||
|
const generatedDTOs = generatedFiles
|
||||||
|
.filter(f => f.endsWith('.ts'))
|
||||||
|
.map(f => f.replace('.ts', ''));
|
||||||
|
|
||||||
|
// All schemas should have corresponding generated DTOs
|
||||||
|
for (const schema of schemas) {
|
||||||
|
expect(generatedDTOs).toContain(schema);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||||
|
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
|
||||||
|
const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
||||||
|
|
||||||
|
if (!dtoExists) continue;
|
||||||
|
|
||||||
|
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
|
||||||
|
|
||||||
|
// Check that all required properties are present
|
||||||
|
if (schema.required) {
|
||||||
|
for (const requiredProp of schema.required) {
|
||||||
|
expect(dtoContent).toContain(requiredProp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all properties are present
|
||||||
|
if (schema.properties) {
|
||||||
|
for (const propName of Object.keys(schema.properties)) {
|
||||||
|
expect(dtoContent).toContain(propName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Generation Integrity', () => {
|
||||||
|
it('should have valid TypeScript syntax in generated files', async () => {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const dtos = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of dtos) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
|
||||||
|
// Basic TypeScript syntax checks
|
||||||
|
expect(content).toContain('export interface');
|
||||||
|
expect(content).toContain('{');
|
||||||
|
expect(content).toContain('}');
|
||||||
|
|
||||||
|
// Should not have syntax errors (basic check)
|
||||||
|
expect(content).not.toContain('undefined;');
|
||||||
|
expect(content).not.toContain('any;');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper imports for dependencies', async () => {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const dtos = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of dtos) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
|
||||||
|
|
||||||
|
for (const importLine of importMatches) {
|
||||||
|
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
|
||||||
|
if (match) {
|
||||||
|
const [, importedType, fromFile] = match;
|
||||||
|
expect(importedType).toBe(fromFile);
|
||||||
|
|
||||||
|
// Check that the imported file exists
|
||||||
|
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
|
||||||
|
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Contract Compatibility', () => {
|
||||||
|
it('should maintain backward compatibility for existing DTOs', async () => {
|
||||||
|
// This test ensures that when regenerating types, existing properties aren't removed
|
||||||
|
// unless explicitly intended
|
||||||
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec: OpenAPISpec = JSON.parse(content);
|
||||||
|
|
||||||
|
// Check critical DTOs that are likely used in production
|
||||||
|
const criticalDTOs = [
|
||||||
|
'RequestAvatarGenerationInputDTO',
|
||||||
|
'RequestAvatarGenerationOutputDTO',
|
||||||
|
'UploadMediaInputDTO',
|
||||||
|
'UploadMediaOutputDTO',
|
||||||
|
'RaceDTO',
|
||||||
|
'DriverDTO'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dtoName of criticalDTOs) {
|
||||||
|
if (spec.components.schemas[dtoName]) {
|
||||||
|
const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
|
||||||
|
const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nullable fields correctly', async () => {
|
||||||
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec: OpenAPISpec = JSON.parse(content);
|
||||||
|
const schemas = spec.components.schemas;
|
||||||
|
|
||||||
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||||
|
if (!schema.properties) continue;
|
||||||
|
|
||||||
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||||
|
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
|
||||||
|
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
|
||||||
|
|
||||||
|
if (propSchema.nullable) {
|
||||||
|
// Nullable properties should be optional in TypeScript
|
||||||
|
const propRegex = new RegExp(`${propName}\\??:\\s*([\\w\\[\\]<>|]+)\\s*;`);
|
||||||
|
const match = dtoContent.match(propRegex);
|
||||||
|
if (match) {
|
||||||
|
// Should include null in the type or be optional
|
||||||
|
expect(match[1]).toContain('| null');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
230
apps/website/lib/types/contractConsumption.test.ts
Normal file
230
apps/website/lib/types/contractConsumption.test.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/**
|
||||||
|
* Contract Consumption Tests for Website
|
||||||
|
*
|
||||||
|
* These tests validate that the website can properly consume and use
|
||||||
|
* the generated API types without type errors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
// Import all generated DTOs to ensure they compile
|
||||||
|
import type { RequestAvatarGenerationInputDTO } from './generated/RequestAvatarGenerationInputDTO';
|
||||||
|
import type { RequestAvatarGenerationOutputDTO } from './generated/RequestAvatarGenerationOutputDTO';
|
||||||
|
import type { UploadMediaInputDTO } from './generated/UploadMediaInputDTO';
|
||||||
|
import type { UploadMediaOutputDTO } from './generated/UploadMediaOutputDTO';
|
||||||
|
import type { GetMediaOutputDTO } from './generated/GetMediaOutputDTO';
|
||||||
|
import type { DeleteMediaOutputDTO } from './generated/DeleteMediaOutputDTO';
|
||||||
|
import type { GetAvatarOutputDTO } from './generated/GetAvatarOutputDTO';
|
||||||
|
import type { UpdateAvatarInputDTO } from './generated/UpdateAvatarInputDTO';
|
||||||
|
import type { UpdateAvatarOutputDTO } from './generated/UpdateAvatarOutputDTO';
|
||||||
|
import type { RaceDTO } from './generated/RaceDTO';
|
||||||
|
import type { DriverDTO } from './generated/DriverDTO';
|
||||||
|
|
||||||
|
describe('Website Contract Consumption', () => {
|
||||||
|
const generatedTypesDir = path.join(__dirname, 'generated');
|
||||||
|
|
||||||
|
describe('Generated Types Availability', () => {
|
||||||
|
it('should have generated types directory', async () => {
|
||||||
|
const exists = await fs.access(generatedTypesDir).then(() => true).catch(() => false);
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have expected DTO files', async () => {
|
||||||
|
const requiredDTOs = [
|
||||||
|
'RequestAvatarGenerationInputDTO.ts',
|
||||||
|
'RequestAvatarGenerationOutputDTO.ts',
|
||||||
|
'UploadMediaInputDTO.ts',
|
||||||
|
'UploadMediaOutputDTO.ts',
|
||||||
|
'GetMediaOutputDTO.ts',
|
||||||
|
'DeleteMediaOutputDTO.ts',
|
||||||
|
'GetAvatarOutputDTO.ts',
|
||||||
|
'UpdateAvatarInputDTO.ts',
|
||||||
|
'UpdateAvatarOutputDTO.ts',
|
||||||
|
'RaceDTO.ts',
|
||||||
|
'DriverDTO.ts'
|
||||||
|
];
|
||||||
|
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const tsFiles = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const required of requiredDTOs) {
|
||||||
|
expect(tsFiles).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have no syntax errors in generated files', async () => {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const dtos = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of dtos) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
|
||||||
|
// Basic syntax validation
|
||||||
|
expect(content).toContain('export interface');
|
||||||
|
expect(content).toContain('{');
|
||||||
|
expect(content).toContain('}');
|
||||||
|
|
||||||
|
// Should not have common syntax errors
|
||||||
|
expect(content).not.toMatch(/interface\s+\w+\s*\{\s*\}/); // Empty interfaces
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Compatibility', () => {
|
||||||
|
it('should allow creating valid DTO objects', () => {
|
||||||
|
// Test RequestAvatarGenerationInputDTO
|
||||||
|
const avatarInput: RequestAvatarGenerationInputDTO = {
|
||||||
|
userId: 'user-123',
|
||||||
|
facePhotoData: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
suitColor: 'red'
|
||||||
|
};
|
||||||
|
expect(avatarInput.userId).toBe('user-123');
|
||||||
|
|
||||||
|
// Test RequestAvatarGenerationOutputDTO (success case)
|
||||||
|
const avatarOutputSuccess: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
requestId: 'req-123',
|
||||||
|
avatarUrls: ['https://example.com/avatar1.png', 'https://example.com/avatar2.png']
|
||||||
|
};
|
||||||
|
expect(avatarOutputSuccess.success).toBe(true);
|
||||||
|
expect(avatarOutputSuccess.avatarUrls).toHaveLength(2);
|
||||||
|
|
||||||
|
// Test RequestAvatarGenerationOutputDTO (failure case)
|
||||||
|
const avatarOutputFailure: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Generation failed'
|
||||||
|
};
|
||||||
|
expect(avatarOutputFailure.success).toBe(false);
|
||||||
|
expect(avatarOutputFailure.errorMessage).toBe('Generation failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle optional fields correctly', () => {
|
||||||
|
// Test DTOs with optional fields
|
||||||
|
const uploadInput: UploadMediaInputDTO = {
|
||||||
|
type: 'image',
|
||||||
|
category: 'avatar'
|
||||||
|
};
|
||||||
|
expect(uploadInput.type).toBe('image');
|
||||||
|
|
||||||
|
// Test with minimal required fields
|
||||||
|
const minimalUpload: UploadMediaInputDTO = {
|
||||||
|
type: 'image'
|
||||||
|
};
|
||||||
|
expect(minimalUpload.type).toBe('image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support array types', () => {
|
||||||
|
const avatarOutput: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: true,
|
||||||
|
requestId: 'req-123',
|
||||||
|
avatarUrls: ['url1', 'url2', 'url3']
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Array.isArray(avatarOutput.avatarUrls)).toBe(true);
|
||||||
|
expect(avatarOutput.avatarUrls?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support nested object types', () => {
|
||||||
|
const race: RaceDTO = {
|
||||||
|
id: 'race-123',
|
||||||
|
name: 'Test Race',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
trackName: 'Test Track',
|
||||||
|
startTime: new Date().toISOString(),
|
||||||
|
status: 'scheduled',
|
||||||
|
maxDrivers: 20,
|
||||||
|
registeredDrivers: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(race.id).toBe('race-123');
|
||||||
|
expect(race.name).toBe('Test Race');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Service Integration', () => {
|
||||||
|
it('should work with service layer patterns', () => {
|
||||||
|
// Simulate a service method that uses DTOs
|
||||||
|
function processAvatarRequest(input: RequestAvatarGenerationInputDTO): RequestAvatarGenerationOutputDTO {
|
||||||
|
// Validate input
|
||||||
|
if (!input.userId || !input.facePhotoData) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Missing required fields'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
requestId: `req-${Date.now()}`,
|
||||||
|
avatarUrls: [`https://cdn.example.com/avatars/${input.userId}.png`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = processAvatarRequest({
|
||||||
|
userId: 'test-user',
|
||||||
|
facePhotoData: 'base64data',
|
||||||
|
suitColor: 'blue'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.avatarUrls).toBeDefined();
|
||||||
|
expect(result.avatarUrls?.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API response parsing', () => {
|
||||||
|
// Simulate API response handling
|
||||||
|
const mockApiResponse = {
|
||||||
|
success: true,
|
||||||
|
requestId: 'req-789',
|
||||||
|
avatarUrls: ['https://cdn.example.com/avatar.png']
|
||||||
|
};
|
||||||
|
|
||||||
|
// Type assertion to ensure it matches DTO
|
||||||
|
const parsedResponse: RequestAvatarGenerationOutputDTO = mockApiResponse;
|
||||||
|
|
||||||
|
expect(parsedResponse.success).toBe(true);
|
||||||
|
expect(parsedResponse.avatarUrls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle missing optional fields', () => {
|
||||||
|
// Test that optional fields can be omitted
|
||||||
|
const minimalOutput: RequestAvatarGenerationOutputDTO = {
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Error occurred'
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(minimalOutput.success).toBe(false);
|
||||||
|
expect(minimalOutput.errorMessage).toBe('Error occurred');
|
||||||
|
// avatarUrls and requestId are optional in failure case
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow type narrowing based on success flag', () => {
|
||||||
|
function handleAvatarResponse(response: RequestAvatarGenerationOutputDTO) {
|
||||||
|
if (response.success) {
|
||||||
|
// Success case - avatarUrls should be available
|
||||||
|
expect(response.avatarUrls).toBeDefined();
|
||||||
|
expect(response.avatarUrls!.length).toBeGreaterThan(0);
|
||||||
|
} else {
|
||||||
|
// Failure case - errorMessage should be available
|
||||||
|
expect(response.errorMessage).toBeDefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAvatarResponse({
|
||||||
|
success: true,
|
||||||
|
requestId: 'req-1',
|
||||||
|
avatarUrls: ['url1']
|
||||||
|
});
|
||||||
|
|
||||||
|
handleAvatarResponse({
|
||||||
|
success: false,
|
||||||
|
errorMessage: 'Failed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface GetRaceDetailParamsDTODTO {
|
export interface AllRacesLeagueFilterDTO {
|
||||||
raceId: string;
|
id: string;
|
||||||
driverId: string;
|
name: string;
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface IracingAuthRedirectResult {
|
export interface AllRacesStatusFilterDTO {
|
||||||
redirectUrl: string;
|
value: string;
|
||||||
state: string;
|
label: string;
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,6 @@ export interface AvailableLeagueDTO {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
game: string;
|
game: string;
|
||||||
drivers: number;
|
drivers: string;
|
||||||
avgViewsPerRace: number;
|
avgViewsPerRace: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface BillingStatsDTO {
|
export interface BillingStatsDTO {
|
||||||
totalSpent: number;
|
totalSpent: string;
|
||||||
pendingAmount: number;
|
pendingAmount: string;
|
||||||
nextPaymentDate: string;
|
nextPaymentDate: string;
|
||||||
nextPaymentAmount: number;
|
nextPaymentAmount: string;
|
||||||
activeSponsorships: number;
|
activeSponsorships: string;
|
||||||
averageMonthlySpend: number;
|
averageMonthlySpend: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,4 @@ export interface DashboardRaceSummaryDTO {
|
|||||||
track: string;
|
track: string;
|
||||||
car: string;
|
car: string;
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
status: string;
|
|
||||||
isMyLeague: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DeleteMediaOutputDTO {
|
export interface DeleteMediaOutputDTO {
|
||||||
success: boolean;
|
success: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface LoginParams {
|
export interface DeletePrizeResultDTO {
|
||||||
email: string;
|
success: boolean;
|
||||||
password: string;
|
}
|
||||||
}
|
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
export interface DriverDTO {
|
export interface DriverDTO {
|
||||||
id: string;
|
id: string;
|
||||||
iracingId: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
position: string;
|
||||||
|
races: string;
|
||||||
|
impressions: string;
|
||||||
|
team: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
export interface DriverProfileDriverSummaryDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
country: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
iracingId: string | null;
|
|
||||||
joinedAt: string;
|
|
||||||
rating: number | null;
|
|
||||||
globalRank: number | null;
|
|
||||||
consistency: number | null;
|
|
||||||
bio: string | null;
|
|
||||||
totalDrivers: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileStatsDTO {
|
|
||||||
totalRaces: number;
|
|
||||||
wins: number;
|
|
||||||
podiums: number;
|
|
||||||
dnfs: number;
|
|
||||||
avgFinish: number | null;
|
|
||||||
bestFinish: number | null;
|
|
||||||
worstFinish: number | null;
|
|
||||||
finishRate: number | null;
|
|
||||||
winRate: number | null;
|
|
||||||
podiumRate: number | null;
|
|
||||||
percentile: number | null;
|
|
||||||
rating: number | null;
|
|
||||||
consistency: number | null;
|
|
||||||
overallRank: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileFinishDistributionDTO {
|
|
||||||
totalRaces: number;
|
|
||||||
wins: number;
|
|
||||||
podiums: number;
|
|
||||||
topTen: number;
|
|
||||||
dnfs: number;
|
|
||||||
other: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileTeamMembershipDTO {
|
|
||||||
teamId: string;
|
|
||||||
teamName: string;
|
|
||||||
teamTag: string | null;
|
|
||||||
role: string;
|
|
||||||
joinedAt: string;
|
|
||||||
isCurrent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileSocialFriendSummaryDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
country: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileSocialSummaryDTO {
|
|
||||||
friendsCount: number;
|
|
||||||
friends: DriverProfileSocialFriendSummaryDTO[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
|
|
||||||
|
|
||||||
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
|
||||||
|
|
||||||
export interface DriverProfileAchievementDTO {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
|
|
||||||
rarity: DriverProfileAchievementRarity;
|
|
||||||
earnedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileSocialHandleDTO {
|
|
||||||
platform: DriverProfileSocialPlatform;
|
|
||||||
handle: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileExtendedProfileDTO {
|
|
||||||
socialHandles: DriverProfileSocialHandleDTO[];
|
|
||||||
achievements: DriverProfileAchievementDTO[];
|
|
||||||
racingStyle: string;
|
|
||||||
favoriteTrack: string;
|
|
||||||
favoriteCar: string;
|
|
||||||
timezone: string;
|
|
||||||
availableHours: string;
|
|
||||||
lookingForTeam: boolean;
|
|
||||||
openToRequests: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverProfileDTO {
|
|
||||||
currentDriver: DriverProfileDriverSummaryDTO | null;
|
|
||||||
stats: DriverProfileStatsDTO | null;
|
|
||||||
finishDistribution: DriverProfileFinishDistributionDTO | null;
|
|
||||||
teamMemberships: DriverProfileTeamMembershipDTO[];
|
|
||||||
socialSummary: DriverProfileSocialSummaryDTO;
|
|
||||||
extendedProfile: DriverProfileExtendedProfileDTO | null;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TeamListItemDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
tag: string;
|
|
||||||
description: string;
|
|
||||||
memberCount: number;
|
|
||||||
leagues: string[];
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
region?: string;
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetAllTeamsOutputDTO {
|
|
||||||
teams: TeamListItemDTO[];
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TeamDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
tag: string;
|
|
||||||
description: string;
|
|
||||||
ownerId: string;
|
|
||||||
leagues: string[];
|
|
||||||
createdAt?: string;
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
region?: string;
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MembershipDTO {
|
|
||||||
role: 'owner' | 'manager' | 'member';
|
|
||||||
joinedAt: string;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetDriverTeamOutputDTO {
|
|
||||||
team: TeamDTO;
|
|
||||||
membership: MembershipDTO;
|
|
||||||
isOwner: boolean;
|
|
||||||
canManage: boolean;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ export interface GetMediaOutputDTO {
|
|||||||
url: string;
|
url: string;
|
||||||
type: string;
|
type: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
/** Format: date-time */
|
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export interface GetSponsorOutputDTO {
|
|
||||||
sponsor: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
logoUrl?: string;
|
|
||||||
websiteUrl?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TeamDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
tag: string;
|
|
||||||
description: string;
|
|
||||||
ownerId: string;
|
|
||||||
leagues: string[];
|
|
||||||
createdAt?: string;
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
region?: string;
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MembershipDTO {
|
|
||||||
role: 'owner' | 'manager' | 'member';
|
|
||||||
joinedAt: string;
|
|
||||||
isActive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetTeamDetailsOutputDTO {
|
|
||||||
team: TeamDTO;
|
|
||||||
membership: MembershipDTO | null;
|
|
||||||
canManage: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TeamJoinRequestDTO {
|
|
||||||
requestId: string;
|
|
||||||
driverId: string;
|
|
||||||
driverName: string;
|
|
||||||
teamId: string;
|
|
||||||
status: 'pending' | 'approved' | 'rejected';
|
|
||||||
requestedAt: string;
|
|
||||||
avatarUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetTeamJoinRequestsOutputDTO {
|
|
||||||
requests: TeamJoinRequestDTO[];
|
|
||||||
pendingCount: number;
|
|
||||||
totalCount: number;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TeamMemberDTO {
|
|
||||||
driverId: string;
|
|
||||||
driverName: string;
|
|
||||||
role: 'owner' | 'manager' | 'member';
|
|
||||||
joinedAt: string;
|
|
||||||
isActive: boolean;
|
|
||||||
avatarUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetTeamMembersOutputDTO {
|
|
||||||
members: TeamMemberDTO[];
|
|
||||||
totalCount: number;
|
|
||||||
ownerCount: number;
|
|
||||||
managerCount: number;
|
|
||||||
memberCount: number;
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ export interface InvoiceDTO {
|
|||||||
invoiceNumber: string;
|
invoiceNumber: string;
|
||||||
date: string;
|
date: string;
|
||||||
dueDate: string;
|
dueDate: string;
|
||||||
amount: number;
|
amount: string;
|
||||||
vatAmount: number;
|
vatAmount: string;
|
||||||
totalAmount: number;
|
totalAmount: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ProtestDTO } from './ProtestDTO';
|
|
||||||
import { RaceDTO } from './RaceDTO';
|
|
||||||
|
|
||||||
export interface DriverSummaryDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeagueAdminProtestsDTO {
|
|
||||||
protests: ProtestDTO[];
|
|
||||||
racesById: { [raceId: string]: RaceDTO };
|
|
||||||
driversById: { [driverId: string]: DriverSummaryDTO };
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,4 @@
|
|||||||
export interface LeagueWithCapacityDTO {
|
export interface LeagueWithCapacityDTO {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
|
||||||
ownerId: string;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
maxDrivers: number;
|
|
||||||
sessionDuration?: number;
|
|
||||||
visibility?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface LoginWithIracingCallbackParams {
|
|
||||||
code: string;
|
|
||||||
state: string;
|
|
||||||
returnTo?: string;
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MemberPaymentDto {
|
export interface MemberPaymentDTO {
|
||||||
id: string;
|
id: string;
|
||||||
feeId: string;
|
feeId: string;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface MembershipFeeDto {
|
export interface MembershipFeeDTO {
|
||||||
id: string;
|
id: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface NotificationSettingsDTO {
|
export interface NotificationSettingsDTO {
|
||||||
emailNewSponsorships: boolean;
|
emailNewSponsorships: string;
|
||||||
emailWeeklyReport: boolean;
|
emailWeeklyReport: string;
|
||||||
emailRaceAlerts: boolean;
|
emailRaceAlerts: string;
|
||||||
emailPaymentAlerts: boolean;
|
emailPaymentAlerts: string;
|
||||||
emailNewOpportunities: boolean;
|
emailNewOpportunities: string;
|
||||||
emailContractExpiry: boolean;
|
emailContractExpiry: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PrivacySettingsDTO {
|
export interface PrivacySettingsDTO {
|
||||||
publicProfile: boolean;
|
publicProfile: string;
|
||||||
showStats: boolean;
|
showStats: string;
|
||||||
showActiveSponsorships: boolean;
|
showActiveSponsorships: string;
|
||||||
allowDirectContact: boolean;
|
allowDirectContact: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface PrizeDto {
|
export interface PrizeDTO {
|
||||||
id: string;
|
id: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export interface RaceDTO {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
views: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RacePenaltyDTO } from './RacePenaltyDTO';
|
|
||||||
|
|
||||||
export interface RacePenaltiesDTO {
|
|
||||||
penalties: RacePenaltyDTO[];
|
|
||||||
driverMap: Record<string, string>;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface RecordEngagementInputDTO {
|
|
||||||
eventType: string;
|
|
||||||
userId?: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface RecordPageViewInputDTO {
|
|
||||||
path: string;
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SignupParams {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
displayName: string;
|
|
||||||
iracingCustomerId?: string;
|
|
||||||
primaryDriverId?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface SponsorDashboardInvestmentDTO {
|
export interface SponsorDashboardInvestmentDTO {
|
||||||
activeSponsorships: number;
|
activeSponsorships: string;
|
||||||
totalInvestment: number;
|
totalInvestment: string;
|
||||||
costPerThousandViews: number;
|
costPerThousandViews: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface SponsorDashboardMetricsDTO {
|
export interface SponsorDashboardMetricsDTO {
|
||||||
impressions: number;
|
impressions: string;
|
||||||
impressionsChange: number;
|
impressionsChange: string;
|
||||||
uniqueViewers: number;
|
uniqueViewers: string;
|
||||||
viewersChange: number;
|
viewersChange: string;
|
||||||
races: number;
|
races: string;
|
||||||
drivers: number;
|
drivers: string;
|
||||||
exposure: number;
|
exposure: string;
|
||||||
exposureChange: number;
|
exposureChange: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
export interface SponsorshipPricingItemDTO {
|
export interface SponsorshipPricingItemDTO {
|
||||||
id: string;
|
id: string;
|
||||||
level: string;
|
level: string;
|
||||||
price: number;
|
price: string;
|
||||||
currency: string;
|
currency: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface TransactionDto {
|
export interface TransactionDTO {
|
||||||
id: string;
|
id: string;
|
||||||
walletId: string;
|
walletId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface UpdateAvatarOutputDTO {
|
export interface UpdateAvatarOutputDTO {
|
||||||
success: boolean;
|
success: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-generated DTO from OpenAPI spec
|
|
||||||
* This file is generated by scripts/generate-api-types.ts
|
|
||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface UpdateTeamInputDTO {
|
|
||||||
name?: string;
|
|
||||||
tag?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface UploadMediaOutputDTO {
|
export interface UploadMediaOutputDTO {
|
||||||
success: boolean;
|
success: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Do not edit manually - regenerate using: npm run api:sync-types
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface WalletDto {
|
export interface WalletDTO {
|
||||||
id: string;
|
id: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
|||||||
271
docs/CONTRACT_TESTING.md
Normal file
271
docs/CONTRACT_TESTING.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
# Contract Testing Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the contract testing strategy for ensuring compatibility between the API (`apps/api`) and the website (`apps/website`) in the GridPilot monorepo.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The contract testing system consists of several layers:
|
||||||
|
|
||||||
|
### 1. API Contract Validation
|
||||||
|
- **Location**: `apps/api/src/shared/testing/contractValidation.test.ts`
|
||||||
|
- **Purpose**: Validates that API DTOs are consistent and generate valid OpenAPI specs
|
||||||
|
- **Tests**:
|
||||||
|
- OpenAPI spec integrity
|
||||||
|
- DTO consistency
|
||||||
|
- Type generation integrity
|
||||||
|
- Contract compatibility
|
||||||
|
|
||||||
|
### 2. Type Generation
|
||||||
|
- **Scripts**:
|
||||||
|
- `npm run api:generate-spec` - Generates OpenAPI spec from DTOs
|
||||||
|
- `npm run api:generate-types` - Generates TypeScript types for website
|
||||||
|
- `npm run api:sync-types` - Runs both in sequence
|
||||||
|
- **Output**: `apps/website/lib/types/generated/`
|
||||||
|
|
||||||
|
### 3. Website Contract Consumption
|
||||||
|
- **Location**: `apps/website/lib/types/contractConsumption.test.ts`
|
||||||
|
- **Purpose**: Validates that website can properly consume generated types
|
||||||
|
- **Tests**:
|
||||||
|
- Generated types availability
|
||||||
|
- Type compatibility
|
||||||
|
- Service integration
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### 4. Compatibility Verification
|
||||||
|
- **Location**: `scripts/contract-compatibility.ts`
|
||||||
|
- **Purpose**: Detects breaking changes between type versions
|
||||||
|
- **Features**:
|
||||||
|
- Backup current types
|
||||||
|
- Generate new types
|
||||||
|
- Compare and detect changes
|
||||||
|
- Report breaking changes
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run full contract testing suite
|
||||||
|
npm run test:contracts
|
||||||
|
|
||||||
|
# Or run individual steps:
|
||||||
|
npm run test:api:contracts # Validate API contracts
|
||||||
|
npm run api:generate-spec # Generate OpenAPI spec
|
||||||
|
npm run api:generate-types # Generate types
|
||||||
|
npm run test:contract:compatibility # Check compatibility
|
||||||
|
npm run website:type-check # Verify website types
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Pipeline
|
||||||
|
|
||||||
|
The GitHub Actions workflow (`.github/workflows/contract-testing.yml`) automatically:
|
||||||
|
|
||||||
|
1. **On Pull Requests**:
|
||||||
|
- Runs all contract tests
|
||||||
|
- Validates API contracts
|
||||||
|
- Generates types
|
||||||
|
- Checks for breaking changes
|
||||||
|
- Verifies website type checking
|
||||||
|
- Uploads generated types as artifacts
|
||||||
|
- Comments results on PR
|
||||||
|
|
||||||
|
2. **On Main Branch Push**:
|
||||||
|
- Runs all tests
|
||||||
|
- Generates and commits updated types
|
||||||
|
|
||||||
|
## Breaking Change Detection
|
||||||
|
|
||||||
|
The system detects several types of changes:
|
||||||
|
|
||||||
|
### Breaking Changes (❌ Fails CI)
|
||||||
|
- **Property Removal**: Required properties removed from DTOs
|
||||||
|
- **Type Changes**: Property types changed (e.g., `string` → `number`)
|
||||||
|
- **Required Field Addition**: New required fields added to existing DTOs
|
||||||
|
|
||||||
|
### Non-Breaking Changes (⚠️ Warning)
|
||||||
|
- **Property Addition**: Optional properties added
|
||||||
|
- **New DTOs**: New DTO types added
|
||||||
|
- **Documentation Changes**: Description updates
|
||||||
|
|
||||||
|
### Removed Changes (❌ Fails CI)
|
||||||
|
- **DTO Removal**: Entire DTO types removed
|
||||||
|
- **Property Removal**: Optional properties removed
|
||||||
|
|
||||||
|
## Generated Types Structure
|
||||||
|
|
||||||
|
The generated types follow this pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Auto-generated DTO from OpenAPI spec
|
||||||
|
* This file is generated by scripts/generate-api-types.ts
|
||||||
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RelatedDTO } from './RelatedDTO';
|
||||||
|
|
||||||
|
export interface MyDTO {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
optionalField?: string;
|
||||||
|
related?: RelatedDTO;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### API Layer Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/shared/testing/contractValidation.test.ts
|
||||||
|
describe('API Contract Validation', () => {
|
||||||
|
it('should have valid OpenAPI spec', async () => {
|
||||||
|
// Validates spec structure
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have no circular references', async () => {
|
||||||
|
// Prevents infinite loops
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain backward compatibility', async () => {
|
||||||
|
// Ensures critical DTOs exist
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Website Layer Tests
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/website/lib/types/contractConsumption.test.ts
|
||||||
|
describe('Website Contract Consumption', () => {
|
||||||
|
it('should allow creating valid DTO objects', () => {
|
||||||
|
// Type-safe object creation
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with service layer patterns', () => {
|
||||||
|
// Integration with services
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle error cases', () => {
|
||||||
|
// Error handling patterns
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Service Layer Integration
|
||||||
|
|
||||||
|
Services in the website can import and use generated types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
|
||||||
|
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||||
|
|
||||||
|
class AvatarService {
|
||||||
|
async generateAvatar(input: RequestAvatarGenerationInputDTO): Promise<RequestAvatarGenerationOutputDTO> {
|
||||||
|
const response = await apiClient.post('/avatar/generate', input);
|
||||||
|
return response.data; // Type-safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Model Integration
|
||||||
|
|
||||||
|
View models can transform DTOs for UI consumption:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
|
|
||||||
|
class RaceViewModel {
|
||||||
|
constructor(private dto: RaceDTO) {}
|
||||||
|
|
||||||
|
get displayDate(): string {
|
||||||
|
return new Date(this.dto.startTime).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Type Generation Fails**
|
||||||
|
- Check that DTOs have proper `@ApiProperty` decorators
|
||||||
|
- Verify OpenAPI spec is valid JSON
|
||||||
|
- Ensure all referenced types exist
|
||||||
|
|
||||||
|
2. **Breaking Changes Detected**
|
||||||
|
- Review the change report
|
||||||
|
- Update website code to handle new types
|
||||||
|
- Consider versioning strategy for major changes
|
||||||
|
|
||||||
|
3. **Website Type Errors**
|
||||||
|
- Run `npm run api:sync-types` to regenerate
|
||||||
|
- Check import paths in website code
|
||||||
|
- Verify TypeScript configuration
|
||||||
|
|
||||||
|
### Debugging Steps
|
||||||
|
|
||||||
|
1. **Check OpenAPI Spec**:
|
||||||
|
```bash
|
||||||
|
npm run api:generate-spec
|
||||||
|
cat apps/api/openapi.json | jq '.components.schemas'
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Compare Generated Types**:
|
||||||
|
```bash
|
||||||
|
# Backup current types
|
||||||
|
cp -r apps/website/lib/types/generated /tmp/types-backup
|
||||||
|
|
||||||
|
# Regenerate
|
||||||
|
npm run api:generate-types
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
diff -r /tmp/types-backup apps/website/lib/types/generated
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run Individual Tests**:
|
||||||
|
```bash
|
||||||
|
# API tests
|
||||||
|
npm run test:api:contracts
|
||||||
|
|
||||||
|
# Website type checking
|
||||||
|
npm run website:type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### For API Developers
|
||||||
|
|
||||||
|
1. **Always use `@ApiProperty` decorators** with proper types
|
||||||
|
2. **Mark optional fields explicitly** with `required: false`
|
||||||
|
3. **Use proper TypeScript types** (avoid `any`)
|
||||||
|
4. **Add descriptions** to DTO properties
|
||||||
|
5. **Test DTOs** with contract validation tests
|
||||||
|
|
||||||
|
### For Website Developers
|
||||||
|
|
||||||
|
1. **Import from generated types**, not manual types
|
||||||
|
2. **Use type assertions** when consuming API responses
|
||||||
|
3. **Handle optional fields** properly
|
||||||
|
4. **Run contract tests** before committing type changes
|
||||||
|
5. **Update view models** when DTOs change
|
||||||
|
|
||||||
|
### For CI/CD
|
||||||
|
|
||||||
|
1. **Run contract tests on every PR**
|
||||||
|
2. **Block merges on breaking changes**
|
||||||
|
3. **Generate types on main branch pushes**
|
||||||
|
4. **Upload artifacts for debugging**
|
||||||
|
5. **Comment results on PRs**
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Add API versioning support
|
||||||
|
- [ ] Generate client SDKs from OpenAPI
|
||||||
|
- [ ] Add contract testing for WebSocket events
|
||||||
|
- [ ] Implement schema registry
|
||||||
|
- [ ] Add automated migration scripts for breaking changes
|
||||||
168
docs/CONTRACT_TESTING_QUICKSTART.md
Normal file
168
docs/CONTRACT_TESTING_QUICKSTART.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Contract Testing Quick Start Guide
|
||||||
|
|
||||||
|
## 🚀 Quick Setup
|
||||||
|
|
||||||
|
### 1. Run the Full Contract Test Suite
|
||||||
|
```bash
|
||||||
|
npm run test:contracts
|
||||||
|
```
|
||||||
|
|
||||||
|
This single command will:
|
||||||
|
- ✅ Validate API contracts
|
||||||
|
- ✅ Generate OpenAPI spec
|
||||||
|
- ✅ Generate TypeScript types
|
||||||
|
- ✅ Check for breaking changes
|
||||||
|
- ✅ Verify website type compatibility
|
||||||
|
|
||||||
|
### 2. Individual Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate API contracts only
|
||||||
|
npm run test:api:contracts
|
||||||
|
|
||||||
|
# Generate types (after making DTO changes)
|
||||||
|
npm run api:sync-types
|
||||||
|
|
||||||
|
# Check compatibility (detect breaking changes)
|
||||||
|
npm run test:contract:compatibility
|
||||||
|
|
||||||
|
# Verify website can consume types
|
||||||
|
npm run website:type-check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 What Gets Created
|
||||||
|
|
||||||
|
### Generated Types
|
||||||
|
- **Location**: `apps/website/lib/types/generated/`
|
||||||
|
- **Files**: One `.ts` file per DTO (e.g., `RaceDTO.ts`, `DriverDTO.ts`)
|
||||||
|
- **Usage**: Import directly in website code
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
- **API Tests**: `apps/api/src/shared/testing/contractValidation.test.ts`
|
||||||
|
- **Website Tests**: `apps/website/lib/types/contractConsumption.test.ts`
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
- **Workflow**: `.github/workflows/contract-testing.yml`
|
||||||
|
- **Triggers**: Pull requests and main branch pushes
|
||||||
|
|
||||||
|
## 🎯 Common Workflows
|
||||||
|
|
||||||
|
### Making API Changes
|
||||||
|
|
||||||
|
1. **Update DTO in API**:
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/domain/race/dtos/RaceDTO.ts
|
||||||
|
export class RaceDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
description?: string; // New optional field
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run contract tests**:
|
||||||
|
```bash
|
||||||
|
npm run test:contracts
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **If tests pass**, commit your changes. The CI will regenerate types automatically.
|
||||||
|
|
||||||
|
### Updating Website Code
|
||||||
|
|
||||||
|
1. **Import generated types**:
|
||||||
|
```typescript
|
||||||
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
|
|
||||||
|
function RaceComponent({ race }: { race: RaceDTO }) {
|
||||||
|
return <div>{race.name}</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **TypeScript will catch errors** if contracts change.
|
||||||
|
|
||||||
|
### Detecting Breaking Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# This will show you exactly what changed
|
||||||
|
npm run test:contract:compatibility
|
||||||
|
```
|
||||||
|
|
||||||
|
Output example:
|
||||||
|
```
|
||||||
|
🚨 BREAKING CHANGES DETECTED:
|
||||||
|
• RaceDTO.status: Property status was removed (BREAKING)
|
||||||
|
|
||||||
|
❌ REMOVED:
|
||||||
|
• OldDTO: DTO OldDTO was removed
|
||||||
|
|
||||||
|
➕ ADDED:
|
||||||
|
• NewDTO: New DTO NewDTO was added
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### "Cannot find module" errors
|
||||||
|
```bash
|
||||||
|
# Regenerate types
|
||||||
|
npm run api:sync-types
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type generation fails
|
||||||
|
1. Check DTOs have `@ApiProperty` decorators
|
||||||
|
2. Verify OpenAPI spec is valid: `cat apps/api/openapi.json`
|
||||||
|
3. Run individual steps:
|
||||||
|
```bash
|
||||||
|
npm run api:generate-spec
|
||||||
|
npm run api:generate-types
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI fails on breaking changes
|
||||||
|
- Review what changed
|
||||||
|
- Update website code to handle new types
|
||||||
|
- Or revert the breaking change if unintended
|
||||||
|
|
||||||
|
## 📋 Checklist Before Committing
|
||||||
|
|
||||||
|
- [ ] Run `npm run test:contracts` locally
|
||||||
|
- [ ] All tests pass
|
||||||
|
- [ ] No breaking changes (or they're intentional)
|
||||||
|
- [ ] Website code updated to handle new types
|
||||||
|
- [ ] Generated types are committed (if needed)
|
||||||
|
|
||||||
|
## 🎓 Key Concepts
|
||||||
|
|
||||||
|
### What is a "Contract"?
|
||||||
|
A contract is the agreement between API and website about what data looks like:
|
||||||
|
- DTO definitions
|
||||||
|
- Property types
|
||||||
|
- Required vs optional fields
|
||||||
|
|
||||||
|
### What are "Breaking Changes"?
|
||||||
|
Changes that would break the website:
|
||||||
|
- Removing required fields
|
||||||
|
- Changing field types
|
||||||
|
- Adding required fields to existing DTOs
|
||||||
|
|
||||||
|
### Why Generate Types?
|
||||||
|
- **Type Safety**: Catch errors at compile time
|
||||||
|
- **Auto-completion**: Better IDE experience
|
||||||
|
- **Documentation**: Types serve as living documentation
|
||||||
|
- **Consistency**: Single source of truth
|
||||||
|
|
||||||
|
## 🚨 Important Notes
|
||||||
|
|
||||||
|
1. **Never manually edit generated files** - they're auto-generated
|
||||||
|
2. **Always run tests before committing** - prevents breaking changes
|
||||||
|
3. **The CI will regenerate types** - but local verification is faster
|
||||||
|
4. **Breaking changes need review** - consider versioning strategy
|
||||||
|
|
||||||
|
## 📚 More Resources
|
||||||
|
|
||||||
|
- Full documentation: `docs/CONTRACT_TESTING.md`
|
||||||
|
- API examples: `apps/api/src/shared/testing/contractValidation.test.ts`
|
||||||
|
- Website examples: `apps/website/lib/types/contractConsumption.test.ts`
|
||||||
|
- CI/CD workflow: `.github/workflows/contract-testing.yml`
|
||||||
@@ -61,6 +61,11 @@
|
|||||||
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
|
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
|
||||||
"api:test": "vitest run --config vitest.api.config.ts",
|
"api:test": "vitest run --config vitest.api.config.ts",
|
||||||
"api:coverage": "vitest run --config vitest.api.config.ts --coverage",
|
"api:coverage": "vitest run --config vitest.api.config.ts --coverage",
|
||||||
|
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
|
||||||
|
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
|
||||||
|
"test:contracts": "tsx scripts/run-contract-tests.ts",
|
||||||
|
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
|
||||||
|
"test:type-generation": "vitest run --config vitest.scripts.config.ts scripts/test/type-generation.test.ts",
|
||||||
"build": "echo 'Build all packages placeholder - to be configured'",
|
"build": "echo 'Build all packages placeholder - to be configured'",
|
||||||
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
|
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
|
||||||
"companion:build": "npm run build --workspace=@gridpilot/companion",
|
"companion:build": "npm run build --workspace=@gridpilot/companion",
|
||||||
|
|||||||
288
scripts/contract-compatibility.ts
Normal file
288
scripts/contract-compatibility.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Contract Compatibility Verification Script
|
||||||
|
*
|
||||||
|
* This script verifies that the API contracts are compatible with the website
|
||||||
|
* by running the type generation and then checking for breaking changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
interface OpenAPISchema {
|
||||||
|
type?: string;
|
||||||
|
format?: string;
|
||||||
|
$ref?: string;
|
||||||
|
items?: OpenAPISchema;
|
||||||
|
properties?: Record<string, OpenAPISchema>;
|
||||||
|
required?: string[];
|
||||||
|
enum?: string[];
|
||||||
|
nullable?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenAPISpec {
|
||||||
|
openapi: string;
|
||||||
|
info: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
paths: Record<string, any>;
|
||||||
|
components: {
|
||||||
|
schemas: Record<string, OpenAPISchema>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContractChange {
|
||||||
|
type: 'added' | 'removed' | 'modified' | 'breaking';
|
||||||
|
dto: string;
|
||||||
|
property?: string;
|
||||||
|
details: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
dim: '\x1b[2m'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runContractCompatibilityCheck(): Promise<void> {
|
||||||
|
console.log(`${colors.blue}🔍 Running Contract Compatibility Check...${colors.reset}\n`);
|
||||||
|
|
||||||
|
const apiRoot = path.join(__dirname, '../apps/api');
|
||||||
|
const websiteRoot = path.join(__dirname, '../apps/website');
|
||||||
|
const openapiPath = path.join(apiRoot, 'openapi.json');
|
||||||
|
const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated');
|
||||||
|
const backupDir = path.join(__dirname, '../.backup/contract-types');
|
||||||
|
|
||||||
|
// Step 1: Generate current OpenAPI spec
|
||||||
|
console.log(`${colors.yellow}1. Generating OpenAPI spec...${colors.reset}`);
|
||||||
|
try {
|
||||||
|
execSync('npm run api:generate-spec', { stdio: 'inherit' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${colors.red}❌ Failed to generate OpenAPI spec${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Backup current generated types
|
||||||
|
console.log(`${colors.yellow}2. Backing up current generated types...${colors.reset}`);
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.ts')) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
await fs.writeFile(path.join(backupDir, file), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.yellow}⚠️ No existing types to backup${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Generate new types
|
||||||
|
console.log(`${colors.yellow}3. Generating new types...${colors.reset}`);
|
||||||
|
try {
|
||||||
|
execSync('npm run api:generate-types', { stdio: 'inherit' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${colors.red}❌ Failed to generate types${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Compare and detect changes
|
||||||
|
console.log(`${colors.yellow}4. Analyzing contract changes...${colors.reset}`);
|
||||||
|
const changes = await detectContractChanges(backupDir, generatedTypesDir, openapiPath);
|
||||||
|
|
||||||
|
// Step 5: Report results
|
||||||
|
console.log(`${colors.yellow}5. Reporting changes...${colors.reset}\n`);
|
||||||
|
await reportChanges(changes);
|
||||||
|
|
||||||
|
// Step 6: Clean up backup
|
||||||
|
console.log(`${colors.yellow}6. Cleaning up...${colors.reset}`);
|
||||||
|
await fs.rm(backupDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
console.log(`\n${colors.green}✅ Contract compatibility check completed!${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectContractChanges(
|
||||||
|
backupDir: string,
|
||||||
|
currentDir: string,
|
||||||
|
openapiPath: string
|
||||||
|
): Promise<ContractChange[]> {
|
||||||
|
const changes: ContractChange[] = [];
|
||||||
|
|
||||||
|
// Read OpenAPI spec
|
||||||
|
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec: OpenAPISpec = JSON.parse(specContent);
|
||||||
|
const schemas = spec.components.schemas;
|
||||||
|
|
||||||
|
// Get current and backup files
|
||||||
|
const currentFiles = await fs.readdir(currentDir);
|
||||||
|
const backupFiles = await fs.readdir(backupDir);
|
||||||
|
|
||||||
|
const currentDTOs = currentFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', ''));
|
||||||
|
const backupDTOs = backupFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', ''));
|
||||||
|
|
||||||
|
// Check for removed DTOs
|
||||||
|
for (const backupDTO of backupDTOs) {
|
||||||
|
if (!currentDTOs.includes(backupDTO)) {
|
||||||
|
changes.push({
|
||||||
|
type: 'removed',
|
||||||
|
dto: backupDTO,
|
||||||
|
details: `DTO ${backupDTO} was removed`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for added and modified DTOs
|
||||||
|
for (const currentDTO of currentDTOs) {
|
||||||
|
const currentPath = path.join(currentDir, `${currentDTO}.ts`);
|
||||||
|
const backupPath = path.join(backupDir, `${currentDTO}.ts`);
|
||||||
|
|
||||||
|
const currentContent = await fs.readFile(currentPath, 'utf-8');
|
||||||
|
const backupExists = backupDTOs.includes(currentDTO);
|
||||||
|
|
||||||
|
if (!backupExists) {
|
||||||
|
changes.push({
|
||||||
|
type: 'added',
|
||||||
|
dto: currentDTO,
|
||||||
|
details: `New DTO ${currentDTO} was added`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const backupContent = await fs.readFile(backupPath, 'utf-8');
|
||||||
|
|
||||||
|
// Check for property changes
|
||||||
|
const schema = schemas[currentDTO];
|
||||||
|
if (schema && schema.properties) {
|
||||||
|
const currentProps = extractProperties(currentContent);
|
||||||
|
const backupProps = extractProperties(backupContent);
|
||||||
|
|
||||||
|
// Check for removed properties
|
||||||
|
for (const [propName, backupProp] of Object.entries(backupProps)) {
|
||||||
|
if (!currentProps[propName]) {
|
||||||
|
const isRequired = schema.required?.includes(propName);
|
||||||
|
changes.push({
|
||||||
|
type: isRequired ? 'breaking' : 'modified',
|
||||||
|
dto: currentDTO,
|
||||||
|
property: propName,
|
||||||
|
details: `Property ${propName} was removed${isRequired ? ' (BREAKING)' : ''}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for added properties
|
||||||
|
for (const [propName, currentProp] of Object.entries(currentProps)) {
|
||||||
|
if (!backupProps[propName]) {
|
||||||
|
const isRequired = schema.required?.includes(propName);
|
||||||
|
changes.push({
|
||||||
|
type: isRequired ? 'breaking' : 'added',
|
||||||
|
dto: currentDTO,
|
||||||
|
property: propName,
|
||||||
|
details: `Property ${propName} was added${isRequired ? ' (potentially breaking)' : ''}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for type changes
|
||||||
|
for (const [propName, currentProp] of Object.entries(currentProps)) {
|
||||||
|
if (backupProps[propName]) {
|
||||||
|
const backupProp = backupProps[propName];
|
||||||
|
if (currentProp.type !== backupProp.type) {
|
||||||
|
changes.push({
|
||||||
|
type: 'breaking',
|
||||||
|
dto: currentDTO,
|
||||||
|
property: propName,
|
||||||
|
details: `Property ${propName} type changed from ${backupProp.type} to ${currentProp.type} (BREAKING)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProperties(content: string): Record<string, { type: string; optional: boolean }> {
|
||||||
|
const properties: Record<string, { type: string; optional: boolean }> = {};
|
||||||
|
|
||||||
|
// Match property lines: propertyName?: type;
|
||||||
|
const propertyRegex = /^\s*(\w+)(\??):\s*([^;]+);/gm;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = propertyRegex.exec(content)) !== null) {
|
||||||
|
const [, name, optional, type] = match;
|
||||||
|
properties[name] = {
|
||||||
|
type: type.trim(),
|
||||||
|
optional: !!optional
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reportChanges(changes: ContractChange[]): Promise<void> {
|
||||||
|
if (changes.length === 0) {
|
||||||
|
console.log(`${colors.green}✅ No changes detected - contracts are stable${colors.reset}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const breaking = changes.filter(c => c.type === 'breaking');
|
||||||
|
const modified = changes.filter(c => c.type === 'modified');
|
||||||
|
const added = changes.filter(c => c.type === 'added');
|
||||||
|
const removed = changes.filter(c => c.type === 'removed');
|
||||||
|
|
||||||
|
if (breaking.length > 0) {
|
||||||
|
console.log(`${colors.red}🚨 BREAKING CHANGES DETECTED:${colors.reset}`);
|
||||||
|
breaking.forEach(change => {
|
||||||
|
console.log(` ${colors.red}• ${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed.length > 0) {
|
||||||
|
console.log(`${colors.red}❌ REMOVED:${colors.reset}`);
|
||||||
|
removed.forEach(change => {
|
||||||
|
console.log(` ${colors.red}• ${change.dto}: ${change.details}${colors.reset}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified.length > 0) {
|
||||||
|
console.log(`${colors.yellow}⚠️ MODIFIED:${colors.reset}`);
|
||||||
|
modified.forEach(change => {
|
||||||
|
console.log(` ${colors.yellow}• ${change.dto}.${change.property}: ${change.details}${colors.reset}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
console.log(`${colors.green}➕ ADDED:${colors.reset}`);
|
||||||
|
added.forEach(change => {
|
||||||
|
console.log(` ${colors.green}• ${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`);
|
||||||
|
});
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalChanges = changes.length;
|
||||||
|
console.log(`${colors.blue}📊 Summary: ${totalChanges} total changes (${breaking.length} breaking, ${removed.length} removed, ${modified.length} modified, ${added.length} added)${colors.reset}`);
|
||||||
|
|
||||||
|
if (breaking.length > 0) {
|
||||||
|
console.log(`\n${colors.red}❌ Contract compatibility check FAILED due to breaking changes${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
runContractCompatibilityCheck().catch(error => {
|
||||||
|
console.error(`${colors.red}❌ Error running contract compatibility check:${colors.reset}`, error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -64,18 +64,42 @@ async function generateIndividualDtoFiles(openapiPath: string, outputDir: string
|
|||||||
|
|
||||||
const schemaNames = Object.keys(schemas);
|
const schemaNames = Object.keys(schemas);
|
||||||
|
|
||||||
|
// Get existing files in output directory
|
||||||
|
let existingFiles: string[] = [];
|
||||||
|
try {
|
||||||
|
existingFiles = await fs.readdir(outputDir);
|
||||||
|
existingFiles = existingFiles.filter(f => f.endsWith('.ts'));
|
||||||
|
} catch (error) {
|
||||||
|
// Directory doesn't exist yet
|
||||||
|
}
|
||||||
|
|
||||||
// Generate individual files for each schema
|
// Generate individual files for each schema
|
||||||
|
const generatedFileNames: string[] = [];
|
||||||
for (const schemaName of schemaNames) {
|
for (const schemaName of schemaNames) {
|
||||||
const schema = schemas[schemaName];
|
const schema = schemas[schemaName];
|
||||||
|
|
||||||
|
// File name should match the schema name exactly
|
||||||
const fileName = `${schemaName}.ts`;
|
const fileName = `${schemaName}.ts`;
|
||||||
const filePath = path.join(outputDir, fileName);
|
const filePath = path.join(outputDir, fileName);
|
||||||
|
|
||||||
const fileContent = generateDtoFileContent(schemaName, schema, schemas);
|
const fileContent = generateDtoFileContent(schemaName, schema, schemas);
|
||||||
await fs.writeFile(filePath, fileContent);
|
await fs.writeFile(filePath, fileContent);
|
||||||
console.log(` ✅ Generated ${fileName}`);
|
console.log(` ✅ Generated ${fileName}`);
|
||||||
|
generatedFileNames.push(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up files that are no longer in the spec
|
||||||
|
const filesToRemove = existingFiles.filter(f => !generatedFileNames.includes(f));
|
||||||
|
for (const file of filesToRemove) {
|
||||||
|
const filePath = path.join(outputDir, file);
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
console.log(` 🗑️ Removed obsolete file: ${file}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Generated ${schemaNames.length} individual DTO files at: ${outputDir}`);
|
console.log(`✅ Generated ${schemaNames.length} individual DTO files at: ${outputDir}`);
|
||||||
|
if (filesToRemove.length > 0) {
|
||||||
|
console.log(`🧹 Cleaned up ${filesToRemove.length} obsolete files`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Record<string, any>): string {
|
function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Record<string, any>): string {
|
||||||
@@ -101,7 +125,7 @@ function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Rec
|
|||||||
content += '\n';
|
content += '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate interface
|
// Generate interface - use the schema name directly
|
||||||
content += `export interface ${schemaName} {\n`;
|
content += `export interface ${schemaName} {\n`;
|
||||||
|
|
||||||
const properties = schema.properties || {};
|
const properties = schema.properties || {};
|
||||||
|
|||||||
@@ -86,12 +86,22 @@ async function processDTOFile(filePath: string, schemas: Record<string, OpenAPIS
|
|||||||
const className = classMatch[1];
|
const className = classMatch[1];
|
||||||
const classBody = classMatch[2];
|
const classBody = classMatch[2];
|
||||||
|
|
||||||
console.log(` 📝 Processing ${className}`);
|
// Normalize class name to always use DTO suffix (not Dto)
|
||||||
|
const normalizedName = className.endsWith('Dto') ?
|
||||||
|
className.slice(0, -3) + 'DTO' : className;
|
||||||
|
|
||||||
|
console.log(` 📝 Processing ${className} -> ${normalizedName}`);
|
||||||
|
|
||||||
|
// Check for conflicts
|
||||||
|
if (schemas[normalizedName]) {
|
||||||
|
console.warn(` ⚠️ Conflict: ${normalizedName} already exists. Skipping duplicate from ${filePath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const schema = extractSchemaFromClassBody(classBody, content);
|
const schema = extractSchemaFromClassBody(classBody, content);
|
||||||
if (schema && Object.keys(schema.properties || {}).length > 0) {
|
if (schema && Object.keys(schema.properties || {}).length > 0) {
|
||||||
schemas[className] = schema;
|
schemas[normalizedName] = schema;
|
||||||
console.log(` ✅ Added ${className} with ${Object.keys(schema.properties || {}).length} properties`);
|
console.log(` ✅ Added ${normalizedName} with ${Object.keys(schema.properties || {}).length} properties`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
scripts/run-contract-tests.ts
Normal file
85
scripts/run-contract-tests.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* Contract Testing Integration Script
|
||||||
|
*
|
||||||
|
* This script runs all contract tests in the correct order:
|
||||||
|
* 1. API contract validation
|
||||||
|
* 2. Type generation
|
||||||
|
* 3. Website contract consumption tests
|
||||||
|
* 4. Compatibility verification
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
dim: '\x1b[2m'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function runContractTests(): Promise<void> {
|
||||||
|
console.log(`${colors.cyan}🚀 Starting Contract Testing Suite${colors.reset}\n`);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
name: 'API Contract Validation',
|
||||||
|
command: 'npm run test:api:contracts',
|
||||||
|
description: 'Validate API DTOs and OpenAPI spec integrity'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate OpenAPI Spec',
|
||||||
|
command: 'npm run api:generate-spec',
|
||||||
|
description: 'Generate OpenAPI specification from DTOs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate TypeScript Types',
|
||||||
|
command: 'npm run api:generate-types',
|
||||||
|
description: 'Generate TypeScript types for website'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Contract Compatibility Check',
|
||||||
|
command: 'npm run test:contract:compatibility',
|
||||||
|
description: 'Check for breaking changes in contracts'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Website Type Checking',
|
||||||
|
command: 'npm run website:type-check',
|
||||||
|
description: 'Verify website can consume generated types'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < steps.length; i++) {
|
||||||
|
const step = steps[i];
|
||||||
|
console.log(`${colors.yellow}${i + 1}/${steps.length} ${step.name}${colors.reset}`);
|
||||||
|
console.log(`${colors.dim} ${step.description}${colors.reset}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(step.command, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, FORCE_COLOR: 'true' }
|
||||||
|
});
|
||||||
|
console.log(`${colors.green} ✅ ${step.name} completed${colors.reset}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`${colors.red} ❌ ${step.name} failed${colors.reset}\n`);
|
||||||
|
console.log(`${colors.red}Contract testing suite failed at step: ${step.name}${colors.reset}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${colors.green}🎉 All contract tests passed!${colors.reset}`);
|
||||||
|
console.log(`${colors.green}✅ Contracts are compatible and validated${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
runContractTests().catch(error => {
|
||||||
|
console.error(`${colors.red}❌ Contract testing suite failed:${colors.reset}`, error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
258
scripts/test/type-generation.test.ts
Normal file
258
scripts/test/type-generation.test.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Test suite for type generation script
|
||||||
|
* Validates that the type generation process works correctly
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { glob } from 'glob';
|
||||||
|
|
||||||
|
describe('Type Generation Script', () => {
|
||||||
|
const apiRoot = path.join(__dirname, '../../apps/api');
|
||||||
|
const websiteRoot = path.join(__dirname, '../../apps/website');
|
||||||
|
const openapiPath = path.join(apiRoot, 'openapi.json');
|
||||||
|
const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated');
|
||||||
|
const backupDir = path.join(__dirname, '../../.backup/type-gen-test');
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Backup existing generated types
|
||||||
|
await fs.mkdir(backupDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.endsWith('.ts')) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
await fs.writeFile(path.join(backupDir, file), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// No existing files to backup
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
// Restore backup
|
||||||
|
try {
|
||||||
|
const backupFiles = await fs.readdir(backupDir);
|
||||||
|
for (const file of backupFiles) {
|
||||||
|
if (file.endsWith('.ts')) {
|
||||||
|
const content = await fs.readFile(path.join(backupDir, file), 'utf-8');
|
||||||
|
await fs.writeFile(path.join(generatedTypesDir, file), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// No backup to restore
|
||||||
|
}
|
||||||
|
// Clean up backup
|
||||||
|
await fs.rm(backupDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OpenAPI Spec Generation', () => {
|
||||||
|
it('should generate valid OpenAPI spec', async () => {
|
||||||
|
// Run the spec generation
|
||||||
|
execSync('npm run api:generate-spec', {
|
||||||
|
cwd: path.join(__dirname, '../..'),
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that spec exists and is valid JSON
|
||||||
|
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
expect(() => JSON.parse(specContent)).not.toThrow();
|
||||||
|
|
||||||
|
const spec = JSON.parse(specContent);
|
||||||
|
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
|
||||||
|
expect(spec.components).toBeDefined();
|
||||||
|
expect(spec.components.schemas).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have duplicate schema names with different casing', async () => {
|
||||||
|
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec = JSON.parse(specContent);
|
||||||
|
const schemas = Object.keys(spec.components.schemas);
|
||||||
|
|
||||||
|
// Check for duplicates with different casing
|
||||||
|
const lowerCaseMap = new Map<string, string[]>();
|
||||||
|
schemas.forEach(schema => {
|
||||||
|
const lower = schema.toLowerCase();
|
||||||
|
if (!lowerCaseMap.has(lower)) {
|
||||||
|
lowerCaseMap.set(lower, []);
|
||||||
|
}
|
||||||
|
lowerCaseMap.get(lower)!.push(schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicates = Array.from(lowerCaseMap.entries())
|
||||||
|
.filter(([_, names]) => names.length > 1);
|
||||||
|
|
||||||
|
expect(duplicates.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate spec with consistent naming', async () => {
|
||||||
|
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec = JSON.parse(specContent);
|
||||||
|
const schemas = Object.keys(spec.components.schemas);
|
||||||
|
|
||||||
|
// All schemas should follow DTO naming convention
|
||||||
|
const invalidNames = schemas.filter(name => !name.endsWith('DTO') && !name.endsWith('Dto'));
|
||||||
|
expect(invalidNames.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Type Generation', () => {
|
||||||
|
it('should generate TypeScript files for all schemas', async () => {
|
||||||
|
// Generate types
|
||||||
|
execSync('npm run api:generate-types', {
|
||||||
|
cwd: path.join(__dirname, '../..'),
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read generated files
|
||||||
|
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||||
|
const generatedDTOs = generatedFiles
|
||||||
|
.filter(f => f.endsWith('.ts'))
|
||||||
|
.map(f => f.replace('.ts', ''));
|
||||||
|
|
||||||
|
// Read OpenAPI spec
|
||||||
|
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec = JSON.parse(specContent);
|
||||||
|
const schemas = Object.keys(spec.components.schemas);
|
||||||
|
|
||||||
|
// Most schemas should have corresponding generated files
|
||||||
|
// (allowing for some duplicates/conflicts that are intentionally skipped)
|
||||||
|
const missingFiles = schemas.filter(schema => !generatedDTOs.includes(schema));
|
||||||
|
|
||||||
|
// Should have at least 95% coverage
|
||||||
|
const coverage = (schemas.length - missingFiles.length) / schemas.length;
|
||||||
|
expect(coverage).toBeGreaterThan(0.95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate files with correct interface names', async () => {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const dtos = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of dtos) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
const interfaceName = file.replace('.ts', '');
|
||||||
|
|
||||||
|
// File should contain an interface (name might be normalized)
|
||||||
|
expect(content).toMatch(/export interface \w+\s*{/);
|
||||||
|
|
||||||
|
// Should not have duplicate interface names in the same file
|
||||||
|
const interfaceMatches = content.match(/export interface (\w+)/g);
|
||||||
|
expect(interfaceMatches?.length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate valid TypeScript syntax', async () => {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const dtos = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of dtos) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
|
||||||
|
// Basic syntax checks
|
||||||
|
expect(content).toContain('export interface');
|
||||||
|
expect(content).toContain('{');
|
||||||
|
expect(content).toContain('}');
|
||||||
|
expect(content).toContain('Auto-generated DTO');
|
||||||
|
|
||||||
|
// Should not have syntax errors
|
||||||
|
expect(content).not.toMatch(/interface\s+\w+\s*\{\s*\}/); // Empty interfaces
|
||||||
|
expect(content).not.toContain('undefined;');
|
||||||
|
expect(content).not.toContain('any;');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dependencies correctly', async () => {
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const dtos = files.filter(f => f.endsWith('.ts'));
|
||||||
|
|
||||||
|
for (const file of dtos) {
|
||||||
|
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||||
|
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
|
||||||
|
|
||||||
|
for (const importLine of importMatches) {
|
||||||
|
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
|
||||||
|
if (match) {
|
||||||
|
const [, importedType, fromFile] = match;
|
||||||
|
|
||||||
|
// Import type should match the file name
|
||||||
|
expect(importedType).toBe(fromFile);
|
||||||
|
|
||||||
|
// The imported file should exist
|
||||||
|
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
|
||||||
|
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
|
||||||
|
expect(exists).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain consistent naming between OpenAPI and generated files', async () => {
|
||||||
|
const specContent = await fs.readFile(openapiPath, 'utf-8');
|
||||||
|
const spec = JSON.parse(specContent);
|
||||||
|
const schemas = Object.keys(spec.components.schemas);
|
||||||
|
|
||||||
|
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||||
|
const generatedDTOs = generatedFiles
|
||||||
|
.filter(f => f.endsWith('.ts'))
|
||||||
|
.map(f => f.replace('.ts', ''));
|
||||||
|
|
||||||
|
// Check that most schemas have matching files (allowing for some edge cases)
|
||||||
|
const missingFiles = schemas.filter(schema => !generatedDTOs.includes(schema));
|
||||||
|
const coverage = (schemas.length - missingFiles.length) / schemas.length;
|
||||||
|
expect(coverage).toBeGreaterThan(0.95);
|
||||||
|
|
||||||
|
// Check that most files have matching schemas (allowing for normalization)
|
||||||
|
const extraFiles = generatedDTOs.filter(dto => !schemas.includes(dto));
|
||||||
|
const extraCoverage = (generatedDTOs.length - extraFiles.length) / generatedDTOs.length;
|
||||||
|
expect(extraCoverage).toBeGreaterThan(0.95);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration', () => {
|
||||||
|
it('should generate types that can be imported without errors', async () => {
|
||||||
|
// Generate types first
|
||||||
|
execSync('npm run api:generate-types', {
|
||||||
|
cwd: path.join(__dirname, '../..'),
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to import a few key DTOs
|
||||||
|
const testDTOs = [
|
||||||
|
'RaceDTO',
|
||||||
|
'DriverDTO',
|
||||||
|
'RequestAvatarGenerationInputDTO',
|
||||||
|
'RequestAvatarGenerationOutputDTO'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dto of testDTOs) {
|
||||||
|
const filePath = path.join(generatedTypesDir, `${dto}.ts`);
|
||||||
|
const exists = await fs.access(filePath).then(() => true).catch(() => false);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
// Should be valid TypeScript that can be parsed
|
||||||
|
expect(content).toContain(`export interface ${dto}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle the full generation workflow', async () => {
|
||||||
|
// Run complete workflow
|
||||||
|
execSync('npm run api:sync-types', {
|
||||||
|
cwd: path.join(__dirname, '../..'),
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify both spec and types were generated
|
||||||
|
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
|
||||||
|
expect(specExists).toBe(true);
|
||||||
|
|
||||||
|
const files = await fs.readdir(generatedTypesDir);
|
||||||
|
const tsFiles = files.filter(f => f.endsWith('.ts'));
|
||||||
|
expect(tsFiles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
18
vitest.scripts.config.ts
Normal file
18
vitest.scripts.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
watch: false,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['scripts/test/**/*.test.ts'],
|
||||||
|
exclude: ['node_modules/**', 'dist/**'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './apps/website'),
|
||||||
|
'@core': resolve(__dirname, './core'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
23
vitest.website.config.ts
Normal file
23
vitest.website.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
watch: false,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['apps/website/lib/types/**/*.test.ts'],
|
||||||
|
exclude: ['node_modules/**', 'apps/website/.next/**', 'dist/**'],
|
||||||
|
typecheck: {
|
||||||
|
enabled: true,
|
||||||
|
checker: 'tsc',
|
||||||
|
include: ['apps/website/lib/types/**/*.test.ts']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './apps/website'),
|
||||||
|
'@core': resolve(__dirname, './core'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user