Files
goodgo-platform/docs/audits/TEST_TEMPLATES.md
Ho Ngoc Hai b8512ebff4 docs: consolidate audit and analysis reports into docs/audits/
Move 36 root-level audit/analysis documents and 7 web app audit documents
into docs/audits/ directory to declutter the project root. Remove stale
EXPLORATION_SUMMARY.txt.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 01:37:50 +07:00

16 KiB

Test Templates for Untested Files

Repository Test Template

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
    });
  });
});

Value Object Test Template

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);
    });
  });
});

DTO Test Template

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);
    });
  });
});

Controller Test Template

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 });
    });
  });
});

Pagination Test Helper

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);
    });
  });
});

Aggregation Test Helper

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,
    });
  });
});

DTO Pagination Helper Test

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);
    });
  });
});