Skip to content

Commit 36d0e42

Browse files
Merge pull request #185 from AbuTuraab/feat-Enforce-Test-Coverage-Failure-Scenario-Testing
feat:Enforce Test Coverage & Failure Scenario Testing
2 parents 981a2f2 + 5369856 commit 36d0e42

File tree

9 files changed

+419
-19
lines changed

9 files changed

+419
-19
lines changed

.github/workflows/ci.yml

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,35 @@ jobs:
100100
with:
101101
path: node_modules
102102
key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
103-
- run: npx jest --config jest.config.js --coverage --forceExit --runInBand
103+
- run: npm run test:ci
104104
env:
105105
NODE_ENV: test
106106
JWT_SECRET: ci-test-secret
107107
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/teachlink_test
108+
- name: Publish coverage summary
109+
if: always()
110+
run: |
111+
node -e "
112+
const fs = require('fs');
113+
const path = 'coverage/coverage-summary.json';
114+
if (!fs.existsSync(path)) {
115+
console.log('Coverage summary file not found.');
116+
process.exit(0);
117+
}
118+
const s = JSON.parse(fs.readFileSync(path, 'utf8')).total;
119+
const row = (name, metric) => '| ' + name + ' | ' + metric.pct.toFixed(2) + '% | ' + metric.covered + '/' + metric.total + ' |';
120+
const summary = [
121+
'## Coverage Summary',
122+
'',
123+
'| Metric | Percentage | Covered/Total |',
124+
'|---|---:|---:|',
125+
row('Lines', s.lines),
126+
row('Statements', s.statements),
127+
row('Functions', s.functions),
128+
row('Branches', s.branches),
129+
].join('\n');
130+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary + '\n');
131+
"
108132
- uses: actions/upload-artifact@v4
109133
if: always()
110134
with: { name: coverage-report, path: coverage/, retention-days: 7 }
@@ -155,13 +179,24 @@ jobs:
155179
ci-success:
156180
name: CI Passed
157181
runs-on: ubuntu-latest
158-
needs: [lint, format, typecheck, build, unit-tests, e2e-tests]
182+
needs: [install, lint, format, typecheck, build, unit-tests, e2e-tests]
159183
if: always()
160184
steps:
161185
- name: Check all jobs passed
186+
env:
187+
NEEDS_JSON: ${{ toJSON(needs) }}
162188
run: |
163-
results="${{ join(needs.*.result, ' ') }}"
164-
for result in $results; do
165-
if [ "$result" != "success" ]; then echo "❌ CI failed." && exit 1; fi
166-
done
167-
echo "✅ All CI jobs passed."
189+
node -e '
190+
const needs = JSON.parse(process.env.NEEDS_JSON);
191+
const failed = [];
192+
for (const [name, info] of Object.entries(needs)) {
193+
const result = info.result;
194+
console.log(`${name}: ${result}`);
195+
if (result !== "success") failed.push(`${name}=${result}`);
196+
}
197+
if (failed.length) {
198+
console.error(`❌ CI failed: ${failed.join(", ")}`);
199+
process.exit(1);
200+
}
201+
console.log("✅ All CI jobs passed.");
202+
'

jest.config.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@ module.exports = {
2525
// text — printed to stdout (CI logs)
2626
// lcov — consumed by GitHub Actions coverage summary step
2727
// html — uploaded as an artifact for visual inspection
28-
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
28+
coverageReporters: ['text', 'lcov', 'html', 'json-summary', 'cobertura'],
2929

3030
// ─── Coverage Thresholds ───────────────────────────────────────────────────
3131
// Pipeline fails if any metric falls below these values.
3232
// Adjust upward incrementally as the test suite matures.
3333
coverageThreshold: {
3434
global: {
35-
branches: 70,
36-
functions: 70,
37-
lines: 70,
38-
statements: 70,
35+
branches: Number(process.env.COVERAGE_THRESHOLD_BRANCHES || 70),
36+
functions: Number(process.env.COVERAGE_THRESHOLD_FUNCTIONS || 70),
37+
lines: Number(process.env.COVERAGE_THRESHOLD_LINES || 70),
38+
statements: Number(process.env.COVERAGE_THRESHOLD_STATEMENTS || 70),
3939
},
4040
},
4141

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"test": "jest",
2222
"test:watch": "jest --watch",
2323
"test:cov": "jest --coverage",
24-
"test:ci": "jest --config jest.config.js --coverage --forceExit --runInBand",
24+
"test:ci": "jest --config jest.config.js --coverage --forceExit --runInBand && node ./test/utils/check-coverage-summary.js",
2525
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
2626
"test:e2e": "jest --config ./test/jest-e2e.json --forceExit --runInBand",
2727
"test:ml-models": "jest src/ml-models --maxWorkers=1 --max-old-space-size=2048",
Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,94 @@
1+
import {
2+
BadRequestException,
3+
NotFoundException,
4+
UnauthorizedException,
5+
} from '@nestjs/common';
16
import { Test, TestingModule } from '@nestjs/testing';
7+
import { CreatePaymentDto } from './dto/create-payment.dto';
28
import { PaymentsController } from './payments.controller';
39
import { PaymentsService } from './payments.service';
10+
import {
11+
expectNotFound,
12+
expectUnauthorized,
13+
expectValidationFailure,
14+
} from '../../test/utils';
415

516
describe('PaymentsController', () => {
617
let controller: PaymentsController;
18+
let paymentsService: {
19+
createPaymentIntent: jest.Mock;
20+
processRefund: jest.Mock;
21+
getInvoice: jest.Mock;
22+
};
23+
24+
const request = { user: { id: 'user-1' } };
25+
const createPaymentDto: CreatePaymentDto = {
26+
courseId: 'course-1',
27+
amount: 120,
28+
provider: 'stripe',
29+
};
730

831
beforeEach(async () => {
32+
paymentsService = {
33+
createPaymentIntent: jest.fn(),
34+
processRefund: jest.fn(),
35+
getInvoice: jest.fn(),
36+
};
37+
938
const module: TestingModule = await Test.createTestingModule({
1039
controllers: [PaymentsController],
11-
providers: [PaymentsService],
40+
providers: [{ provide: PaymentsService, useValue: paymentsService }],
1241
}).compile();
1342

1443
controller = module.get<PaymentsController>(PaymentsController);
1544
});
1645

17-
it('should be defined', () => {
18-
expect(controller).toBeDefined();
46+
it('returns payment intent for valid request', async () => {
47+
paymentsService.createPaymentIntent.mockResolvedValue({
48+
paymentId: 'payment-1',
49+
clientSecret: 'cs_123',
50+
requiresAction: false,
51+
});
52+
53+
await expect(
54+
controller.createPaymentIntent(request, createPaymentDto),
55+
).resolves.toMatchObject({
56+
paymentId: 'payment-1',
57+
clientSecret: 'cs_123',
58+
requiresAction: false,
59+
});
60+
61+
expect(paymentsService.createPaymentIntent).toHaveBeenCalledWith(
62+
'user-1',
63+
createPaymentDto,
64+
);
65+
});
66+
67+
it('returns validation failure for invalid refund request', async () => {
68+
paymentsService.processRefund.mockRejectedValue(
69+
new BadRequestException('Invalid refund amount'),
70+
);
71+
72+
await expectValidationFailure(() =>
73+
controller.processRefund({ paymentId: 'payment-1', amount: -1 }),
74+
);
75+
});
76+
77+
it('returns not found when invoice is missing', async () => {
78+
paymentsService.getInvoice.mockRejectedValue(
79+
new NotFoundException('Payment not found'),
80+
);
81+
82+
await expectNotFound(() => controller.getInvoice('missing', request));
83+
});
84+
85+
it('returns unauthorized when access token is invalid', async () => {
86+
paymentsService.createPaymentIntent.mockRejectedValue(
87+
new UnauthorizedException('Invalid token'),
88+
);
89+
90+
await expectUnauthorized(() =>
91+
controller.createPaymentIntent(request, createPaymentDto),
92+
);
1993
});
2094
});
Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,195 @@
1+
import {
2+
BadRequestException,
3+
NotFoundException,
4+
UnauthorizedException,
5+
} from '@nestjs/common';
16
import { Test, TestingModule } from '@nestjs/testing';
7+
import { getRepositoryToken } from '@nestjs/typeorm';
8+
import { Invoice } from './entities/invoice.entity';
9+
import { Payment, PaymentStatus } from './entities/payment.entity';
10+
import { Refund } from './entities/refund.entity';
11+
import { Subscription } from './entities/subscription.entity';
12+
import { CreatePaymentDto } from './dto/create-payment.dto';
213
import { PaymentsService } from './payments.service';
14+
import { User } from '../users/entities/user.entity';
15+
import {
16+
expectNotFound,
17+
expectUnauthorized,
18+
expectValidationFailure,
19+
} from '../../test/utils';
20+
21+
type RepoMock = {
22+
create: jest.Mock;
23+
save: jest.Mock;
24+
findOne: jest.Mock;
25+
find: jest.Mock;
26+
update: jest.Mock;
27+
};
28+
29+
function createRepositoryMock(): RepoMock {
30+
return {
31+
create: jest.fn(),
32+
save: jest.fn(),
33+
findOne: jest.fn(),
34+
find: jest.fn(),
35+
update: jest.fn(),
36+
};
37+
}
338

439
describe('PaymentsService', () => {
540
let service: PaymentsService;
41+
let paymentRepository: RepoMock;
42+
let userRepository: RepoMock;
43+
let refundRepository: RepoMock;
44+
let invoiceRepository: RepoMock;
45+
46+
const baseCreatePaymentDto: CreatePaymentDto = {
47+
courseId: 'course-1',
48+
amount: 100,
49+
currency: 'USD',
50+
provider: 'stripe',
51+
metadata: { source: 'test' },
52+
};
653

754
beforeEach(async () => {
855
const module: TestingModule = await Test.createTestingModule({
9-
providers: [PaymentsService],
56+
providers: [
57+
PaymentsService,
58+
{
59+
provide: getRepositoryToken(Payment),
60+
useValue: createRepositoryMock(),
61+
},
62+
{
63+
provide: getRepositoryToken(Subscription),
64+
useValue: createRepositoryMock(),
65+
},
66+
{
67+
provide: getRepositoryToken(User),
68+
useValue: createRepositoryMock(),
69+
},
70+
{
71+
provide: getRepositoryToken(Refund),
72+
useValue: createRepositoryMock(),
73+
},
74+
{
75+
provide: getRepositoryToken(Invoice),
76+
useValue: createRepositoryMock(),
77+
},
78+
],
1079
}).compile();
1180

1281
service = module.get<PaymentsService>(PaymentsService);
82+
paymentRepository = module.get(getRepositoryToken(Payment));
83+
userRepository = module.get(getRepositoryToken(User));
84+
refundRepository = module.get(getRepositoryToken(Refund));
85+
invoiceRepository = module.get(getRepositoryToken(Invoice));
86+
});
87+
88+
it('creates payment intent for valid user', async () => {
89+
userRepository.findOne.mockResolvedValue({ id: 'user-1' });
90+
paymentRepository.create.mockReturnValue({
91+
id: 'payment-1',
92+
...baseCreatePaymentDto,
93+
status: PaymentStatus.PENDING,
94+
});
95+
paymentRepository.save.mockResolvedValue(undefined);
96+
97+
const provider = {
98+
createPaymentIntent: jest.fn().mockResolvedValue({
99+
paymentIntentId: 'pi_123',
100+
clientSecret: 'cs_123',
101+
requiresAction: false,
102+
}),
103+
};
104+
jest.spyOn(service as any, 'getProvider').mockReturnValue(provider);
105+
106+
await expect(
107+
service.createPaymentIntent('user-1', baseCreatePaymentDto),
108+
).resolves.toMatchObject({
109+
paymentId: 'payment-1',
110+
clientSecret: 'cs_123',
111+
requiresAction: false,
112+
});
113+
});
114+
115+
it('returns not found when user does not exist', async () => {
116+
userRepository.findOne.mockResolvedValue(null);
117+
118+
await expectNotFound(() =>
119+
service.createPaymentIntent('missing-user', baseCreatePaymentDto),
120+
);
121+
});
122+
123+
it('returns not found when refund payment does not exist', async () => {
124+
paymentRepository.findOne.mockResolvedValue(null);
125+
126+
await expectNotFound(() =>
127+
service.processRefund({ paymentId: 'missing', reason: 'duplicate' }),
128+
);
129+
});
130+
131+
it('returns validation failure when refunding non-completed payment', async () => {
132+
paymentRepository.findOne.mockResolvedValue({
133+
id: 'payment-1',
134+
provider: 'stripe',
135+
status: PaymentStatus.PENDING,
136+
});
137+
138+
await expectValidationFailure(() =>
139+
service.processRefund({ paymentId: 'payment-1', reason: 'duplicate' }),
140+
);
141+
});
142+
143+
it('returns not found when invoice payment is missing', async () => {
144+
paymentRepository.findOne.mockResolvedValue(null);
145+
146+
await expectNotFound(() => service.getInvoice('payment-1', 'user-1'));
13147
});
14148

15-
it('should be defined', () => {
16-
expect(service).toBeDefined();
149+
it('supports unauthorized flow when provider rejects a request', async () => {
150+
userRepository.findOne.mockResolvedValue({ id: 'user-1' });
151+
jest.spyOn(service as any, 'getProvider').mockReturnValue({
152+
createPaymentIntent: jest
153+
.fn()
154+
.mockRejectedValue(new UnauthorizedException('Invalid provider token')),
155+
});
156+
157+
await expectUnauthorized(() =>
158+
service.createPaymentIntent('user-1', baseCreatePaymentDto),
159+
);
160+
});
161+
162+
it('uses pagination offset for user payment history', async () => {
163+
paymentRepository.find.mockResolvedValue([]);
164+
165+
await service.getUserPayments('user-1', 20, 3);
166+
167+
expect(paymentRepository.find).toHaveBeenCalledWith(
168+
expect.objectContaining({
169+
where: { userId: 'user-1' },
170+
skip: 40,
171+
take: 20,
172+
}),
173+
);
174+
});
175+
176+
it('throws business validation error type for non-completed refund', async () => {
177+
paymentRepository.findOne.mockResolvedValue({
178+
id: 'payment-2',
179+
provider: 'stripe',
180+
status: PaymentStatus.PENDING,
181+
});
182+
183+
await expect(
184+
service.processRefund({ paymentId: 'payment-2', reason: 'duplicate' }),
185+
).rejects.toBeInstanceOf(BadRequestException);
186+
});
187+
188+
it('throws not found type when user is missing', async () => {
189+
userRepository.findOne.mockResolvedValue(null);
190+
191+
await expect(
192+
service.createPaymentIntent('missing-user', baseCreatePaymentDto),
193+
).rejects.toBeInstanceOf(NotFoundException);
17194
});
18195
});

0 commit comments

Comments
 (0)