From f019676a6549dedb72dcc3c341bc4b9429253481 Mon Sep 17 00:00:00 2001 From: AbuTuraab Date: Wed, 25 Feb 2026 09:45:59 +0100 Subject: [PATCH 1/2] feat:Enforce Test Coverage & Failure Scenario Testing --- .github/workflows/ci.yml | 26 +++- jest.config.js | 10 +- package.json | 2 +- src/payments/payments.controller.spec.ts | 80 +++++++++- src/payments/payments.service.spec.ts | 183 ++++++++++++++++++++++- test/utils/check-coverage-summary.js | 42 ++++++ test/utils/http-outcome-assertions.ts | 34 +++++ test/utils/index.ts | 2 + test/utils/module-test-cases.ts | 36 +++++ 9 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 test/utils/check-coverage-summary.js create mode 100644 test/utils/http-outcome-assertions.ts create mode 100644 test/utils/index.ts create mode 100644 test/utils/module-test-cases.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c2680e..cf4f57d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,11 +100,35 @@ jobs: with: path: node_modules key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }} - - run: npx jest --config jest.config.js --coverage --forceExit --runInBand + - run: npm run test:ci env: NODE_ENV: test JWT_SECRET: ci-test-secret DATABASE_URL: postgresql://postgres:postgres@localhost:5432/teachlink_test + - name: Publish coverage summary + if: always() + run: | + node -e " + const fs = require('fs'); + const path = 'coverage/coverage-summary.json'; + if (!fs.existsSync(path)) { + console.log('Coverage summary file not found.'); + process.exit(0); + } + const s = JSON.parse(fs.readFileSync(path, 'utf8')).total; + const row = (name, metric) => '| ' + name + ' | ' + metric.pct.toFixed(2) + '% | ' + metric.covered + '/' + metric.total + ' |'; + const summary = [ + '## Coverage Summary', + '', + '| Metric | Percentage | Covered/Total |', + '|---|---:|---:|', + row('Lines', s.lines), + row('Statements', s.statements), + row('Functions', s.functions), + row('Branches', s.branches), + ].join('\n'); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n'); + " - uses: actions/upload-artifact@v4 if: always() with: { name: coverage-report, path: coverage/, retention-days: 7 } diff --git a/jest.config.js b/jest.config.js index 32723b8..4a4380a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,17 +25,17 @@ module.exports = { // text — printed to stdout (CI logs) // lcov — consumed by GitHub Actions coverage summary step // html — uploaded as an artifact for visual inspection - coverageReporters: ['text', 'lcov', 'html', 'json-summary'], + coverageReporters: ['text', 'lcov', 'html', 'json-summary', 'cobertura'], // ─── Coverage Thresholds ─────────────────────────────────────────────────── // Pipeline fails if any metric falls below these values. // Adjust upward incrementally as the test suite matures. coverageThreshold: { global: { - branches: 70, - functions: 70, - lines: 70, - statements: 70, + branches: Number(process.env.COVERAGE_THRESHOLD_BRANCHES || 70), + functions: Number(process.env.COVERAGE_THRESHOLD_FUNCTIONS || 70), + lines: Number(process.env.COVERAGE_THRESHOLD_LINES || 70), + statements: Number(process.env.COVERAGE_THRESHOLD_STATEMENTS || 70), }, }, diff --git a/package.json b/package.json index 6ae52cf..c24c92f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", - "test:ci": "jest --config jest.config.js --coverage --forceExit --runInBand", + "test:ci": "jest --config jest.config.js --coverage --forceExit --runInBand && node ./test/utils/check-coverage-summary.js", "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.json --forceExit --runInBand", "test:ml-models": "jest src/ml-models --maxWorkers=1 --max-old-space-size=2048", diff --git a/src/payments/payments.controller.spec.ts b/src/payments/payments.controller.spec.ts index 69f71db..24e4b69 100644 --- a/src/payments/payments.controller.spec.ts +++ b/src/payments/payments.controller.spec.ts @@ -1,20 +1,94 @@ +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { CreatePaymentDto } from './dto/create-payment.dto'; import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; +import { + expectNotFound, + expectUnauthorized, + expectValidationFailure, +} from '../../test/utils'; describe('PaymentsController', () => { let controller: PaymentsController; + let paymentsService: { + createPaymentIntent: jest.Mock; + processRefund: jest.Mock; + getInvoice: jest.Mock; + }; + + const request = { user: { id: 'user-1' } }; + const createPaymentDto: CreatePaymentDto = { + courseId: 'course-1', + amount: 120, + provider: 'stripe', + }; beforeEach(async () => { + paymentsService = { + createPaymentIntent: jest.fn(), + processRefund: jest.fn(), + getInvoice: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [PaymentsController], - providers: [PaymentsService], + providers: [{ provide: PaymentsService, useValue: paymentsService }], }).compile(); controller = module.get(PaymentsController); }); - it('should be defined', () => { - expect(controller).toBeDefined(); + it('returns payment intent for valid request', async () => { + paymentsService.createPaymentIntent.mockResolvedValue({ + paymentId: 'payment-1', + clientSecret: 'cs_123', + requiresAction: false, + }); + + await expect( + controller.createPaymentIntent(request, createPaymentDto), + ).resolves.toMatchObject({ + paymentId: 'payment-1', + clientSecret: 'cs_123', + requiresAction: false, + }); + + expect(paymentsService.createPaymentIntent).toHaveBeenCalledWith( + 'user-1', + createPaymentDto, + ); + }); + + it('returns validation failure for invalid refund request', async () => { + paymentsService.processRefund.mockRejectedValue( + new BadRequestException('Invalid refund amount'), + ); + + await expectValidationFailure(() => + controller.processRefund({ paymentId: 'payment-1', amount: -1 }), + ); + }); + + it('returns not found when invoice is missing', async () => { + paymentsService.getInvoice.mockRejectedValue( + new NotFoundException('Payment not found'), + ); + + await expectNotFound(() => controller.getInvoice('missing', request)); + }); + + it('returns unauthorized when access token is invalid', async () => { + paymentsService.createPaymentIntent.mockRejectedValue( + new UnauthorizedException('Invalid token'), + ); + + await expectUnauthorized(() => + controller.createPaymentIntent(request, createPaymentDto), + ); }); }); diff --git a/src/payments/payments.service.spec.ts b/src/payments/payments.service.spec.ts index cc50520..7214b94 100644 --- a/src/payments/payments.service.spec.ts +++ b/src/payments/payments.service.spec.ts @@ -1,18 +1,195 @@ +import { + BadRequestException, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Invoice } from './entities/invoice.entity'; +import { Payment, PaymentStatus } from './entities/payment.entity'; +import { Refund } from './entities/refund.entity'; +import { Subscription } from './entities/subscription.entity'; +import { CreatePaymentDto } from './dto/create-payment.dto'; import { PaymentsService } from './payments.service'; +import { User } from '../users/entities/user.entity'; +import { + expectNotFound, + expectUnauthorized, + expectValidationFailure, +} from '../../test/utils'; + +type RepoMock = { + create: jest.Mock; + save: jest.Mock; + findOne: jest.Mock; + find: jest.Mock; + update: jest.Mock; +}; + +function createRepositoryMock(): RepoMock { + return { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + }; +} describe('PaymentsService', () => { let service: PaymentsService; + let paymentRepository: RepoMock; + let userRepository: RepoMock; + let refundRepository: RepoMock; + let invoiceRepository: RepoMock; + + const baseCreatePaymentDto: CreatePaymentDto = { + courseId: 'course-1', + amount: 100, + currency: 'USD', + provider: 'stripe', + metadata: { source: 'test' }, + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [PaymentsService], + providers: [ + PaymentsService, + { + provide: getRepositoryToken(Payment), + useValue: createRepositoryMock(), + }, + { + provide: getRepositoryToken(Subscription), + useValue: createRepositoryMock(), + }, + { + provide: getRepositoryToken(User), + useValue: createRepositoryMock(), + }, + { + provide: getRepositoryToken(Refund), + useValue: createRepositoryMock(), + }, + { + provide: getRepositoryToken(Invoice), + useValue: createRepositoryMock(), + }, + ], }).compile(); service = module.get(PaymentsService); + paymentRepository = module.get(getRepositoryToken(Payment)); + userRepository = module.get(getRepositoryToken(User)); + refundRepository = module.get(getRepositoryToken(Refund)); + invoiceRepository = module.get(getRepositoryToken(Invoice)); + }); + + it('creates payment intent for valid user', async () => { + userRepository.findOne.mockResolvedValue({ id: 'user-1' }); + paymentRepository.create.mockReturnValue({ + id: 'payment-1', + ...baseCreatePaymentDto, + status: PaymentStatus.PENDING, + }); + paymentRepository.save.mockResolvedValue(undefined); + + const provider = { + createPaymentIntent: jest.fn().mockResolvedValue({ + paymentIntentId: 'pi_123', + clientSecret: 'cs_123', + requiresAction: false, + }), + }; + jest.spyOn(service as any, 'getProvider').mockReturnValue(provider); + + await expect( + service.createPaymentIntent('user-1', baseCreatePaymentDto), + ).resolves.toMatchObject({ + paymentId: 'payment-1', + clientSecret: 'cs_123', + requiresAction: false, + }); + }); + + it('returns not found when user does not exist', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expectNotFound(() => + service.createPaymentIntent('missing-user', baseCreatePaymentDto), + ); + }); + + it('returns not found when refund payment does not exist', async () => { + paymentRepository.findOne.mockResolvedValue(null); + + await expectNotFound(() => + service.processRefund({ paymentId: 'missing', reason: 'duplicate' }), + ); + }); + + it('returns validation failure when refunding non-completed payment', async () => { + paymentRepository.findOne.mockResolvedValue({ + id: 'payment-1', + provider: 'stripe', + status: PaymentStatus.PENDING, + }); + + await expectValidationFailure(() => + service.processRefund({ paymentId: 'payment-1', reason: 'duplicate' }), + ); + }); + + it('returns not found when invoice payment is missing', async () => { + paymentRepository.findOne.mockResolvedValue(null); + + await expectNotFound(() => service.getInvoice('payment-1', 'user-1')); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('supports unauthorized flow when provider rejects a request', async () => { + userRepository.findOne.mockResolvedValue({ id: 'user-1' }); + jest.spyOn(service as any, 'getProvider').mockReturnValue({ + createPaymentIntent: jest + .fn() + .mockRejectedValue(new UnauthorizedException('Invalid provider token')), + }); + + await expectUnauthorized(() => + service.createPaymentIntent('user-1', baseCreatePaymentDto), + ); + }); + + it('uses pagination offset for user payment history', async () => { + paymentRepository.find.mockResolvedValue([]); + + await service.getUserPayments('user-1', 20, 3); + + expect(paymentRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-1' }, + skip: 40, + take: 20, + }), + ); + }); + + it('throws business validation error type for non-completed refund', async () => { + paymentRepository.findOne.mockResolvedValue({ + id: 'payment-2', + provider: 'stripe', + status: PaymentStatus.PENDING, + }); + + await expect( + service.processRefund({ paymentId: 'payment-2', reason: 'duplicate' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws not found type when user is missing', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.createPaymentIntent('missing-user', baseCreatePaymentDto), + ).rejects.toBeInstanceOf(NotFoundException); }); }); diff --git a/test/utils/check-coverage-summary.js b/test/utils/check-coverage-summary.js new file mode 100644 index 0000000..d258a6b --- /dev/null +++ b/test/utils/check-coverage-summary.js @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); + +const summaryPath = path.resolve(process.cwd(), 'coverage/coverage-summary.json'); + +if (!fs.existsSync(summaryPath)) { + console.error(`Coverage summary not found at ${summaryPath}`); + process.exit(1); +} + +const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8')); +const globalCoverage = summary.total; + +const thresholds = { + lines: Number(process.env.COVERAGE_THRESHOLD_LINES || 70), + statements: Number(process.env.COVERAGE_THRESHOLD_STATEMENTS || 70), + functions: Number(process.env.COVERAGE_THRESHOLD_FUNCTIONS || 70), + branches: Number(process.env.COVERAGE_THRESHOLD_BRANCHES || 70), +}; + +const metrics = ['lines', 'statements', 'functions', 'branches']; +const failed = []; + +for (const metric of metrics) { + const actual = globalCoverage[metric]?.pct ?? 0; + const expected = thresholds[metric]; + if (actual < expected) { + failed.push({ metric, actual, expected }); + } +} + +if (failed.length > 0) { + console.error('Coverage threshold check failed:'); + for (const { metric, actual, expected } of failed) { + console.error( + ` - ${metric}: ${actual.toFixed(2)}% is below required ${expected}%`, + ); + } + process.exit(1); +} + +console.log('Coverage threshold check passed.'); diff --git a/test/utils/http-outcome-assertions.ts b/test/utils/http-outcome-assertions.ts new file mode 100644 index 0000000..235080c --- /dev/null +++ b/test/utils/http-outcome-assertions.ts @@ -0,0 +1,34 @@ +import { HttpException } from '@nestjs/common'; + +export async function expectHttpError( + callback: () => Promise, + statusCode: number, +) { + await expect(callback()).rejects.toMatchObject({ + status: statusCode, + }); +} + +export async function expectSuccess(callback: () => Promise) { + await expect(callback()).resolves.toBeDefined(); +} + +export async function expectNotFound(callback: () => Promise) { + await expectHttpError(callback, 404); +} + +export async function expectValidationFailure(callback: () => Promise) { + await expectHttpError(callback, 400); +} + +export async function expectUnauthorized(callback: () => Promise) { + await expectHttpError(callback, 401); +} + +export function expectHttpExceptionWithMessage( + error: unknown, + message: string, +) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).message).toContain(message); +} diff --git a/test/utils/index.ts b/test/utils/index.ts new file mode 100644 index 0000000..31f4841 --- /dev/null +++ b/test/utils/index.ts @@ -0,0 +1,2 @@ +export * from './http-outcome-assertions'; +export * from './module-test-cases'; diff --git a/test/utils/module-test-cases.ts b/test/utils/module-test-cases.ts new file mode 100644 index 0000000..a701934 --- /dev/null +++ b/test/utils/module-test-cases.ts @@ -0,0 +1,36 @@ +import { + expectNotFound, + expectSuccess, + expectUnauthorized, + expectValidationFailure, +} from './http-outcome-assertions'; + +type ScenarioCallbacks = { + success: () => Promise; + validationFailure: () => Promise; + notFound: () => Promise; + unauthorized: () => Promise; +}; + +export function runStandardHttpScenarios( + suiteName: string, + scenarios: ScenarioCallbacks, +) { + describe(suiteName, () => { + it('handles success scenarios', async () => { + await expectSuccess(scenarios.success); + }); + + it('handles validation failures', async () => { + await expectValidationFailure(scenarios.validationFailure); + }); + + it('handles not found cases', async () => { + await expectNotFound(scenarios.notFound); + }); + + it('handles unauthorized access', async () => { + await expectUnauthorized(scenarios.unauthorized); + }); + }); +} From 5369856c427f175f0a77c208d07c1a682925cfe8 Mon Sep 17 00:00:00 2001 From: AbuTuraab Date: Wed, 25 Feb 2026 13:05:08 +0100 Subject: [PATCH 2/2] feat:Enforce Test Coverage & Failure Scenario Testing --- .github/workflows/ci.yml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf4f57d..397754e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,13 +179,24 @@ jobs: ci-success: name: CI Passed runs-on: ubuntu-latest - needs: [lint, format, typecheck, build, unit-tests, e2e-tests] + needs: [install, lint, format, typecheck, build, unit-tests, e2e-tests] if: always() steps: - name: Check all jobs passed + env: + NEEDS_JSON: ${{ toJSON(needs) }} run: | - results="${{ join(needs.*.result, ' ') }}" - for result in $results; do - if [ "$result" != "success" ]; then echo "❌ CI failed." && exit 1; fi - done - echo "✅ All CI jobs passed." + node -e ' + const needs = JSON.parse(process.env.NEEDS_JSON); + const failed = []; + for (const [name, info] of Object.entries(needs)) { + const result = info.result; + console.log(`${name}: ${result}`); + if (result !== "success") failed.push(`${name}=${result}`); + } + if (failed.length) { + console.error(`❌ CI failed: ${failed.join(", ")}`); + process.exit(1); + } + console.log("✅ All CI jobs passed."); + '