diff --git a/.github/workflows/backend-ci-cd.yml b/.github/workflows/backend-ci-cd.yml index 72612a1d..ba277906 100644 --- a/.github/workflows/backend-ci-cd.yml +++ b/.github/workflows/backend-ci-cd.yml @@ -59,6 +59,9 @@ jobs: - name: Generate OpenAPI and SDK artifacts run: pnpm run generate:api-sdk + - name: Run contract tests + run: cd backend && npm run test:contract + - name: Upload generated SDK artifact uses: actions/upload-artifact@v4 with: diff --git a/backend/package.json b/backend/package.json index b433b815..2e752657 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,6 +20,7 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.js", "test:e2e:ci": "jest --config ./test/jest-e2e.js --runInBand", + "test:contract": "npm run generate:openapi && jest --config ./test/jest-contract.js", "generate:openapi": "ts-node -r tsconfig-paths/register scripts/generate-openapi-spec.ts", "generate:sdk": "pnpm --dir .. run generate:api-sdk", "generate:error-docs": "ts-node -r tsconfig-paths/register src/scripts/generate-error-docs.ts" @@ -63,6 +64,7 @@ "ioredis": "^5.11.1", "isomorphic-dompurify": "^3.15.0", "joi": "^18.0.2", + "js-yaml": "^5.2.0", "multer": "^2.1.1", "nestjs-pino": "^4.6.1", "nodemailer": "^8.0.1", @@ -93,6 +95,7 @@ "@types/compression": "^1.7.6", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/js-yaml": "^4.0.9", "@types/multer": "^2.0.0", "@types/node": "^22.10.7", "@types/nodemailer": "^7.0.11", diff --git a/backend/src/modules/feature-flags/feature-flags.service.ts b/backend/src/modules/feature-flags/feature-flags.service.ts index d5c51a86..d970d99b 100644 --- a/backend/src/modules/feature-flags/feature-flags.service.ts +++ b/backend/src/modules/feature-flags/feature-flags.service.ts @@ -283,4 +283,3 @@ export class FeatureFlagsService { await this.cacheManager.del(this.cacheKey(key)); } } -} diff --git a/backend/test/jest-contract.js b/backend/test/jest-contract.js new file mode 100644 index 00000000..ff7912a2 --- /dev/null +++ b/backend/test/jest-contract.js @@ -0,0 +1,19 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '..', + testEnvironment: 'node', + testRegex: '.contract.spec.ts$', + transform: { + '^.+\\.(t|j)s$': [ + 'ts-jest', + { + tsconfig: { + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + moduleResolution: 'bundler', + types: ['jest', 'node'], + }, + }, + ], + }, +}; diff --git a/backend/test/openapi-contract.spec.ts b/backend/test/openapi-contract.spec.ts new file mode 100644 index 00000000..aaf4c3b3 --- /dev/null +++ b/backend/test/openapi-contract.spec.ts @@ -0,0 +1,41 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +describe('OpenAPI Contract Tests', () => { + let openApiSpec: any; + + beforeAll(() => { + const specPath = path.join(__dirname, '../openapi-v2.json'); + if (!fs.existsSync(specPath)) { + throw new Error(`OpenAPI spec not found at ${specPath}. Run 'pnpm run generate:openapi' first.`); + } + openApiSpec = JSON.parse(fs.readFileSync(specPath, 'utf-8')); + }); + + it('should match the snapshot', () => { + expect(openApiSpec).toMatchSnapshot(); + }); + + it('should have required paths', () => { + const requiredPaths = [ + '/api/v2/auth/login', + '/api/v2/auth/register', + ]; + + requiredPaths.forEach((path) => { + expect(openApiSpec.paths).toHaveProperty(path); + }); + }); + + it('should have required components', () => { + const requiredComponents = [ + 'PageDto', + 'PaginatedResponseDto', + 'StandardErrorResponseDto', + ]; + + requiredComponents.forEach((component) => { + expect(openApiSpec.components.schemas).toHaveProperty(component); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb3eb83..ee72d70a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: joi: specifier: ^18.0.2 version: 18.1.2 + js-yaml: + specifier: ^5.2.0 + version: 5.2.0 multer: specifier: ^2.1.1 version: 2.1.1 @@ -241,6 +244,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 '@types/multer': specifier: ^2.0.0 version: 2.1.0 @@ -2985,6 +2991,9 @@ packages: '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -5807,6 +5816,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@5.2.0: + resolution: {integrity: sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw==} + hasBin: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -11434,6 +11447,8 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -15037,6 +15052,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.2.0: + dependencies: + argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@1.8.0): dependencies: '@asamuzakjp/css-color': 5.1.11