Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
567 lines
16 KiB
Markdown
567 lines
16 KiB
Markdown
# Mẫu Kiểm Thử cho Các Tệp Chưa Được Kiểm Thử
|
|
|
|
## Mẫu Kiểm Thử Repository
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { PrismaInquiryRepository } from '../prisma-inquiry.repository';
|
|
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
|
|
|
|
describe('PrismaInquiryRepository', () => {
|
|
let repository: PrismaInquiryRepository;
|
|
let mockPrisma: any;
|
|
|
|
beforeEach(() => {
|
|
mockPrisma = {
|
|
inquiry: {
|
|
findUnique: vi.fn(),
|
|
findMany: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
count: vi.fn(),
|
|
},
|
|
};
|
|
repository = new PrismaInquiryRepository(mockPrisma);
|
|
});
|
|
|
|
describe('findById', () => {
|
|
it('should return inquiry entity when found', async () => {
|
|
const inquiryData = {
|
|
id: 'inquiry-1',
|
|
listingId: 'listing-1',
|
|
userId: 'user-1',
|
|
message: 'Test message',
|
|
phone: '0901234567',
|
|
isRead: false,
|
|
createdAt: new Date('2026-01-01'),
|
|
};
|
|
mockPrisma.inquiry.findUnique.mockResolvedValue(inquiryData);
|
|
|
|
const result = await repository.findById('inquiry-1');
|
|
|
|
expect(mockPrisma.inquiry.findUnique).toHaveBeenCalledWith({
|
|
where: { id: 'inquiry-1' },
|
|
});
|
|
expect(result).toBeInstanceOf(InquiryEntity);
|
|
});
|
|
|
|
it('should return null when not found', async () => {
|
|
mockPrisma.inquiry.findUnique.mockResolvedValue(null);
|
|
|
|
const result = await repository.findById('non-existent');
|
|
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('save', () => {
|
|
it('should create inquiry with correct data', async () => {
|
|
const entity = new InquiryEntity('inquiry-1', {
|
|
listingId: 'listing-1',
|
|
userId: 'user-1',
|
|
message: 'Test',
|
|
phone: '0901234567',
|
|
isRead: false,
|
|
}, new Date());
|
|
|
|
await repository.save(entity);
|
|
|
|
expect(mockPrisma.inquiry.create).toHaveBeenCalledWith({
|
|
data: {
|
|
id: 'inquiry-1',
|
|
listingId: 'listing-1',
|
|
userId: 'user-1',
|
|
message: 'Test',
|
|
phone: '0901234567',
|
|
isRead: false,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('findByListing', () => {
|
|
it('should return paginated results with correct limit clamping', async () => {
|
|
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
|
mockPrisma.inquiry.count.mockResolvedValue(0);
|
|
|
|
await repository.findByListing('listing-1', 1, 150); // limit > 100
|
|
|
|
const calls = mockPrisma.inquiry.findMany.mock.calls[0][0];
|
|
expect(calls.take).toBe(100); // Clamped to 100
|
|
expect(calls.skip).toBe(0); // (1 - 1) * 100
|
|
});
|
|
|
|
it('should calculate pagination correctly', async () => {
|
|
const mockData = Array(20).fill({
|
|
id: 'id',
|
|
listingId: 'listing-1',
|
|
userId: 'user-1',
|
|
message: 'msg',
|
|
phone: 'phone',
|
|
isRead: false,
|
|
createdAt: new Date(),
|
|
listing: { property: { title: 'Property' } },
|
|
user: { id: 'user-1', fullName: 'Name', phone: 'phone' },
|
|
});
|
|
mockPrisma.inquiry.findMany.mockResolvedValue(mockData);
|
|
mockPrisma.inquiry.count.mockResolvedValue(150);
|
|
|
|
const result = await repository.findByListing('listing-1', 2, 20);
|
|
|
|
expect(result.page).toBe(2);
|
|
expect(result.limit).toBe(20);
|
|
expect(result.total).toBe(150);
|
|
expect(result.totalPages).toBe(8); // ceil(150/20)
|
|
const calls = mockPrisma.inquiry.findMany.mock.calls[0][0];
|
|
expect(calls.skip).toBe(20); // (2 - 1) * 20
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Mẫu Kiểm Thử Value Object
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
import { LeadScore } from '../lead-score.vo';
|
|
|
|
describe('LeadScore ValueObject', () => {
|
|
describe('create', () => {
|
|
it('should create valid score', () => {
|
|
const result = LeadScore.create(50);
|
|
|
|
expect(result.isOk()).toBe(true);
|
|
expect(result.unwrap().value).toBe(50);
|
|
});
|
|
|
|
it('should accept boundary values', () => {
|
|
expect(LeadScore.create(0).isOk()).toBe(true);
|
|
expect(LeadScore.create(100).isOk()).toBe(true);
|
|
});
|
|
|
|
it('should reject negative values', () => {
|
|
const result = LeadScore.create(-1);
|
|
|
|
expect(result.isErr()).toBe(true);
|
|
expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100');
|
|
});
|
|
|
|
it('should reject values over 100', () => {
|
|
const result = LeadScore.create(101);
|
|
|
|
expect(result.isErr()).toBe(true);
|
|
expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100');
|
|
});
|
|
|
|
it('should reject non-integer values', () => {
|
|
const result = LeadScore.create(50.5);
|
|
|
|
expect(result.isErr()).toBe(true);
|
|
});
|
|
|
|
it('should have correct getter', () => {
|
|
const score = LeadScore.create(75).unwrap();
|
|
expect(score.value).toBe(75);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Mẫu Kiểm Thử DTO
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { validate } from 'class-validator';
|
|
import { CreateLeadDto } from '../create-lead.dto';
|
|
|
|
describe('CreateLeadDto', () => {
|
|
let dto: CreateLeadDto;
|
|
|
|
beforeEach(() => {
|
|
dto = new CreateLeadDto();
|
|
});
|
|
|
|
describe('validation', () => {
|
|
it('should pass with all required fields', async () => {
|
|
dto.name = 'Nguyễn Văn A';
|
|
dto.phone = '0901234567';
|
|
dto.source = 'website';
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors).toHaveLength(0);
|
|
});
|
|
|
|
it('should fail when name is missing', async () => {
|
|
dto.phone = '0901234567';
|
|
dto.source = 'website';
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors[0]?.property).toBe('name');
|
|
});
|
|
|
|
it('should fail when phone is missing', async () => {
|
|
dto.name = 'Nguyễn Văn A';
|
|
dto.source = 'website';
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors[0]?.property).toBe('phone');
|
|
});
|
|
|
|
it('should pass with optional email field', async () => {
|
|
dto.name = 'Nguyễn Văn A';
|
|
dto.phone = '0901234567';
|
|
dto.source = 'website';
|
|
// email not set
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors).toHaveLength(0);
|
|
});
|
|
|
|
it('should fail with invalid email format', async () => {
|
|
dto.name = 'Nguyễn Văn A';
|
|
dto.phone = '0901234567';
|
|
dto.source = 'website';
|
|
dto.email = 'invalid-email';
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some(e => e.property === 'email')).toBe(true);
|
|
});
|
|
|
|
it('should fail when score is outside range', async () => {
|
|
dto.name = 'Nguyễn Văn A';
|
|
dto.phone = '0901234567';
|
|
dto.source = 'website';
|
|
dto.score = 150;
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
expect(errors.some(e => e.property === 'score')).toBe(true);
|
|
});
|
|
|
|
it('should pass with score in valid range', async () => {
|
|
dto.name = 'Nguyễn Văn A';
|
|
dto.phone = '0901234567';
|
|
dto.source = 'website';
|
|
dto.score = 75;
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Mẫu Kiểm Thử Controller
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { InquiriesController } from '../inquiries.controller';
|
|
import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command';
|
|
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
|
|
|
|
describe('InquiriesController', () => {
|
|
let controller: InquiriesController;
|
|
let mockCommandBus: any;
|
|
let mockQueryBus: any;
|
|
let mockUser: any;
|
|
|
|
beforeEach(() => {
|
|
mockCommandBus = { execute: vi.fn() };
|
|
mockQueryBus = { execute: vi.fn() };
|
|
mockUser = { sub: 'user-1' };
|
|
controller = new InquiriesController(mockCommandBus, mockQueryBus);
|
|
});
|
|
|
|
describe('createInquiry', () => {
|
|
it('should dispatch CreateInquiryCommand with correct parameters', async () => {
|
|
const dto = {
|
|
listingId: 'listing-1',
|
|
message: 'Test message',
|
|
phone: '0901234567',
|
|
};
|
|
const expected = {
|
|
id: 'inquiry-1',
|
|
listingId: 'listing-1',
|
|
createdAt: new Date(),
|
|
};
|
|
mockCommandBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.createInquiry(dto as any, mockUser);
|
|
|
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
|
expect.any(CreateInquiryCommand),
|
|
);
|
|
const command = mockCommandBus.execute.mock.calls[0][0];
|
|
expect(command.userId).toBe('user-1');
|
|
expect(command.listingId).toBe('listing-1');
|
|
expect(command.message).toBe('Test message');
|
|
expect(command.phone).toBe('0901234567');
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('should convert undefined phone to null', async () => {
|
|
const dto = {
|
|
listingId: 'listing-1',
|
|
message: 'Test message',
|
|
// phone undefined
|
|
};
|
|
mockCommandBus.execute.mockResolvedValue({ id: 'inquiry-1' });
|
|
|
|
await controller.createInquiry(dto as any, mockUser);
|
|
|
|
const command = mockCommandBus.execute.mock.calls[0][0];
|
|
expect(command.phone).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getByListing', () => {
|
|
it('should dispatch GetInquiriesByListingQuery with defaults', async () => {
|
|
const dto = { page: undefined, limit: undefined };
|
|
const expected = { data: [], total: 0, page: 1, limit: 20 };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getByListing('listing-1', dto as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetInquiriesByListingQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0][0];
|
|
expect(query.listingId).toBe('listing-1');
|
|
expect(query.page).toBe(1);
|
|
expect(query.limit).toBe(20);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('should use provided pagination values', async () => {
|
|
const dto = { page: 3, limit: 50 };
|
|
mockQueryBus.execute.mockResolvedValue({ data: [] });
|
|
|
|
await controller.getByListing('listing-1', dto as any);
|
|
|
|
const query = mockQueryBus.execute.mock.calls[0][0];
|
|
expect(query.page).toBe(3);
|
|
expect(query.limit).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('markAsRead', () => {
|
|
it('should dispatch command and return success', async () => {
|
|
mockCommandBus.execute.mockResolvedValue(undefined);
|
|
|
|
const result = await controller.markAsRead('inquiry-1', mockUser);
|
|
|
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
|
expect.any(MarkInquiryReadCommand),
|
|
);
|
|
const command = mockCommandBus.execute.mock.calls[0][0];
|
|
expect(command.inquiryId).toBe('inquiry-1');
|
|
expect(command.userId).toBe('user-1');
|
|
expect(result).toEqual({ success: true });
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Trình Trợ Giúp Kiểm Thử Phân Trang
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
describe('Pagination calculations', () => {
|
|
it('should calculate skip correctly', () => {
|
|
const testCases = [
|
|
{ page: 1, take: 20, expectedSkip: 0 },
|
|
{ page: 2, take: 20, expectedSkip: 20 },
|
|
{ page: 5, take: 50, expectedSkip: 200 },
|
|
];
|
|
|
|
testCases.forEach(({ page, take, expectedSkip }) => {
|
|
const skip = (page - 1) * take;
|
|
expect(skip).toBe(expectedSkip);
|
|
});
|
|
});
|
|
|
|
it('should calculate totalPages correctly', () => {
|
|
const testCases = [
|
|
{ total: 0, take: 20, expectedPages: 0 },
|
|
{ total: 20, take: 20, expectedPages: 1 },
|
|
{ total: 21, take: 20, expectedPages: 2 },
|
|
{ total: 150, take: 20, expectedPages: 8 },
|
|
];
|
|
|
|
testCases.forEach(({ total, take, expectedPages }) => {
|
|
const totalPages = Math.ceil(total / take);
|
|
expect(totalPages).toBe(expectedPages);
|
|
});
|
|
});
|
|
|
|
it('should clamp limit to 100', () => {
|
|
const testCases = [
|
|
{ limit: 10, expectedTake: 10 },
|
|
{ limit: 100, expectedTake: 100 },
|
|
{ limit: 150, expectedTake: 100 },
|
|
{ limit: 1000, expectedTake: 100 },
|
|
];
|
|
|
|
testCases.forEach(({ limit, expectedTake }) => {
|
|
const take = Math.min(limit, 100);
|
|
expect(take).toBe(expectedTake);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Trình Trợ Giúp Kiểm Thử Tổng Hợp
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
describe('Aggregation calculations', () => {
|
|
it('should calculate conversion rate correctly', () => {
|
|
const testCases = [
|
|
{ totalLeads: 10, convertedCount: 5, expected: 50.00 },
|
|
{ totalLeads: 100, convertedCount: 33, expected: 33.00 },
|
|
{ totalLeads: 0, convertedCount: 0, expected: 0 },
|
|
{ totalLeads: 3, convertedCount: 1, expected: 33.33 },
|
|
];
|
|
|
|
testCases.forEach(({ totalLeads, convertedCount, expected }) => {
|
|
const rate = totalLeads > 0
|
|
? Math.round((convertedCount / totalLeads) * 10000) / 100
|
|
: 0;
|
|
expect(rate).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should calculate average score correctly', () => {
|
|
const testCases = [
|
|
{ scores: [100], expected: 100.0 },
|
|
{ scores: [75, 85], expected: 80.0 },
|
|
{ scores: [60, 70, 80], expected: 70.0 },
|
|
{ scores: [], expected: null },
|
|
];
|
|
|
|
testCases.forEach(({ scores, expected }) => {
|
|
const scoreCount = scores.length;
|
|
const scoreSum = scores.reduce((a, b) => a + b, 0);
|
|
const avg = scoreCount > 0
|
|
? Math.round((scoreSum / scoreCount) * 10) / 10
|
|
: null;
|
|
expect(avg).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should calculate average rating correctly', () => {
|
|
const testCases = [
|
|
{ ratings: [5, 5, 5], expected: 5.0 },
|
|
{ ratings: [1, 2, 3, 4, 5], expected: 3.0 },
|
|
{ ratings: [4, 4, 5], expected: 4.3 },
|
|
{ ratings: [], expected: 0 },
|
|
];
|
|
|
|
testCases.forEach(({ ratings, expected }) => {
|
|
const total = ratings.length;
|
|
const sum = ratings.reduce((a, b) => a + b, 0);
|
|
const avg = total > 0
|
|
? Math.round((sum / total) * 10) / 10
|
|
: 0;
|
|
expect(avg).toBe(expected);
|
|
});
|
|
});
|
|
|
|
it('should build rating distribution correctly', () => {
|
|
const ratings = [1, 3, 3, 4, 5, 5, 5];
|
|
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
|
|
for (const rating of ratings) {
|
|
distribution[rating] = (distribution[rating] ?? 0) + 1;
|
|
}
|
|
|
|
expect(distribution).toEqual({
|
|
1: 1,
|
|
2: 0,
|
|
3: 2,
|
|
4: 1,
|
|
5: 3,
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## Kiểm Thử Trình Trợ Giúp Phân Trang DTO
|
|
|
|
```typescript
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { validate } from 'class-validator';
|
|
import { plainToClass } from 'class-transformer';
|
|
import { ListLeadsDto } from '../list-leads.dto';
|
|
|
|
describe('ListLeadsDto - Pagination', () => {
|
|
describe('type transformation', () => {
|
|
it('should transform string page to number', async () => {
|
|
const plain = { page: '2', limit: '50', status: 'NEW' };
|
|
const dto = plainToClass(ListLeadsDto, plain);
|
|
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors).toHaveLength(0);
|
|
expect(dto.page).toBe(2);
|
|
expect(typeof dto.page).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('validation', () => {
|
|
it('should fail when page is 0', async () => {
|
|
const dto = plainToClass(ListLeadsDto, { page: 0 });
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should fail when limit exceeds 100', async () => {
|
|
const dto = plainToClass(ListLeadsDto, { limit: 150 });
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should pass with valid status enum', async () => {
|
|
const dto = plainToClass(ListLeadsDto, { status: 'NEW' });
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors).toHaveLength(0);
|
|
});
|
|
|
|
it('should fail with invalid status', async () => {
|
|
const dto = plainToClass(ListLeadsDto, { status: 'INVALID_STATUS' });
|
|
const errors = await validate(dto);
|
|
|
|
expect(errors.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|