Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -155,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.");
'
10 changes: 5 additions & 5 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
},

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 77 additions & 3 deletions src/payments/payments.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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),
);
});
});
183 changes: 180 additions & 3 deletions src/payments/payments.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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);
});
});
Loading
Loading