diff --git a/apps/api/src/modules/reference/application/__tests__/vn-admin.service.spec.ts b/apps/api/src/modules/reference/application/__tests__/vn-admin.service.spec.ts new file mode 100644 index 0000000..41ac777 --- /dev/null +++ b/apps/api/src/modules/reference/application/__tests__/vn-admin.service.spec.ts @@ -0,0 +1,267 @@ +import { VnAdminService } from '../vn-admin.service'; + +describe('VnAdminService', () => { + let service: VnAdminService; + let mockPrisma: { + vnProvince: { findMany: ReturnType }; + vnDistrict: { findMany: ReturnType; findFirst: ReturnType }; + vnWard: { findMany: ReturnType }; + vnAdministrativeAlias: { findFirst: ReturnType }; + }; + + beforeEach(() => { + mockPrisma = { + vnProvince: { findMany: vi.fn() }, + vnDistrict: { findMany: vi.fn(), findFirst: vi.fn() }, + vnWard: { findMany: vi.fn() }, + vnAdministrativeAlias: { findFirst: vi.fn() }, + }; + service = new VnAdminService(mockPrisma as any); + }); + + // ── listProvinces ────────────────────────────────────────────────── + + describe('listProvinces', () => { + it('returns all provinces ordered by name ascending', async () => { + const provinces = [ + { code: '01', name: 'Hà Nội', type: 'thanh_pho', codename: 'thanh_pho_ha_noi' }, + { code: '79', name: 'TP Hồ Chí Minh', type: 'thanh_pho', codename: 'thanh_pho_ho_chi_minh' }, + ]; + mockPrisma.vnProvince.findMany.mockResolvedValue(provinces); + + const result = await service.listProvinces(); + + expect(result).toEqual(provinces); + expect(mockPrisma.vnProvince.findMany).toHaveBeenCalledWith({ + orderBy: [{ name: 'asc' }], + select: { code: true, name: true, type: true, codename: true }, + }); + }); + + it('returns an empty array when no provinces exist', async () => { + mockPrisma.vnProvince.findMany.mockResolvedValue([]); + + const result = await service.listProvinces(); + + expect(result).toEqual([]); + }); + }); + + // ── listDistricts ────────────────────────────────────────────────── + + describe('listDistricts', () => { + it('returns districts filtered by provinceCode', async () => { + const districts = [ + { code: '760', provinceCode: '79', name: 'Quận 1', type: 'quan', codename: 'quan_1' }, + { code: '769', provinceCode: '79', name: 'Thành phố Thủ Đức', type: 'thanh_pho', codename: 'thanh_pho_thu_duc' }, + ]; + mockPrisma.vnDistrict.findMany.mockResolvedValue(districts); + + const result = await service.listDistricts('79'); + + expect(result).toEqual(districts); + expect(mockPrisma.vnDistrict.findMany).toHaveBeenCalledWith({ + where: { provinceCode: '79' }, + orderBy: [{ name: 'asc' }], + select: { code: true, provinceCode: true, name: true, type: true, codename: true }, + }); + }); + + it('returns an empty array when province has no districts', async () => { + mockPrisma.vnDistrict.findMany.mockResolvedValue([]); + + const result = await service.listDistricts('99'); + + expect(result).toEqual([]); + }); + }); + + // ── listWards ────────────────────────────────────────────────────── + + describe('listWards', () => { + it('returns wards filtered by districtCode', async () => { + const wards = [ + { code: '26734', districtCode: '760', name: 'Phường Bến Nghé', type: 'phuong', codename: 'phuong_ben_nghe' }, + { code: '26737', districtCode: '760', name: 'Phường Bến Thành', type: 'phuong', codename: 'phuong_ben_thanh' }, + ]; + mockPrisma.vnWard.findMany.mockResolvedValue(wards); + + const result = await service.listWards('760'); + + expect(result).toEqual(wards); + expect(mockPrisma.vnWard.findMany).toHaveBeenCalledWith({ + where: { districtCode: '760' }, + orderBy: [{ name: 'asc' }], + select: { code: true, districtCode: true, name: true, type: true, codename: true }, + }); + }); + + it('returns an empty array when district has no wards', async () => { + mockPrisma.vnWard.findMany.mockResolvedValue([]); + + const result = await service.listWards('999'); + + expect(result).toEqual([]); + }); + }); + + // ── resolveDistrict ──────────────────────────────────────────────── + + describe('resolveDistrict', () => { + it('returns null resolution for empty string input', async () => { + const result = await service.resolveDistrict(''); + + expect(result).toEqual({ + districtCode: null, + isCurrent: false, + resolvedViaAlias: false, + aliasReason: null, + }); + expect(mockPrisma.vnDistrict.findFirst).not.toHaveBeenCalled(); + expect(mockPrisma.vnAdministrativeAlias.findFirst).not.toHaveBeenCalled(); + }); + + it('returns null resolution for whitespace-only input', async () => { + const result = await service.resolveDistrict(' '); + + expect(result).toEqual({ + districtCode: null, + isCurrent: false, + resolvedViaAlias: false, + aliasReason: null, + }); + }); + + it('resolves a current district name directly', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' }); + + const result = await service.resolveDistrict('Quận 1'); + + expect(result).toEqual({ + districtCode: '760', + isCurrent: true, + resolvedViaAlias: false, + aliasReason: null, + }); + expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({ + where: { name: 'Quận 1' }, + select: { code: true }, + }); + }); + + it('filters by provinceCode when provided', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' }); + + await service.resolveDistrict('Quận 1', '79'); + + expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({ + where: { name: 'Quận 1', provinceCode: '79' }, + select: { code: true }, + }); + }); + + it('resolves a historical alias when no direct match is found', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue(null); + mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({ + newDistrictCode: '769', + reason: 'Sáp nhập vào Thủ Đức', + }); + + const result = await service.resolveDistrict('Quận 2'); + + expect(result).toEqual({ + districtCode: '769', + isCurrent: false, + resolvedViaAlias: true, + aliasReason: 'Sáp nhập vào Thủ Đức', + }); + expect(mockPrisma.vnAdministrativeAlias.findFirst).toHaveBeenCalledWith({ + where: { oldName: 'Quận 2', level: 'district' }, + select: { newDistrictCode: true, reason: true }, + }); + }); + + it('returns null resolution when neither direct nor alias match', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue(null); + mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue(null); + + const result = await service.resolveDistrict('Nonexistent District'); + + expect(result).toEqual({ + districtCode: null, + isCurrent: false, + resolvedViaAlias: false, + aliasReason: null, + }); + }); + + it('returns null resolution when alias exists but has no newDistrictCode', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue(null); + mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({ + newDistrictCode: null, + reason: 'Removed', + }); + + const result = await service.resolveDistrict('Old Name'); + + expect(result).toEqual({ + districtCode: null, + isCurrent: false, + resolvedViaAlias: false, + aliasReason: null, + }); + }); + + it('trims whitespace from input before matching', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' }); + + await service.resolveDistrict(' Quận 1 '); + + expect(mockPrisma.vnDistrict.findFirst).toHaveBeenCalledWith({ + where: { name: 'Quận 1' }, + select: { code: true }, + }); + }); + + it('skips alias lookup when direct match succeeds', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' }); + + await service.resolveDistrict('Quận 1'); + + expect(mockPrisma.vnAdministrativeAlias.findFirst).not.toHaveBeenCalled(); + }); + }); + + // ── isKnownDistrictName ──────────────────────────────────────────── + + describe('isKnownDistrictName', () => { + it('returns true when resolveDistrict finds a districtCode', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue({ code: '760' }); + + const result = await service.isKnownDistrictName('Quận 1'); + + expect(result).toBe(true); + }); + + it('returns false when resolveDistrict finds no districtCode', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue(null); + mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue(null); + + const result = await service.isKnownDistrictName('Unknown District'); + + expect(result).toBe(false); + }); + + it('returns true when district is resolved via alias', async () => { + mockPrisma.vnDistrict.findFirst.mockResolvedValue(null); + mockPrisma.vnAdministrativeAlias.findFirst.mockResolvedValue({ + newDistrictCode: '769', + reason: 'Merged', + }); + + const result = await service.isKnownDistrictName('Quận 9'); + + expect(result).toBe(true); + }); + }); +}); diff --git a/apps/api/src/modules/reference/presentation/__tests__/vn-admin.controller.spec.ts b/apps/api/src/modules/reference/presentation/__tests__/vn-admin.controller.spec.ts new file mode 100644 index 0000000..cc6c5f3 --- /dev/null +++ b/apps/api/src/modules/reference/presentation/__tests__/vn-admin.controller.spec.ts @@ -0,0 +1,151 @@ +import { BadRequestException } from '@nestjs/common'; +import { VnAdminController } from '../vn-admin.controller'; + +describe('VnAdminController', () => { + let controller: VnAdminController; + let mockVnAdmin: { + listProvinces: ReturnType; + listDistricts: ReturnType; + listWards: ReturnType; + resolveDistrict: ReturnType; + }; + + beforeEach(() => { + mockVnAdmin = { + listProvinces: vi.fn(), + listDistricts: vi.fn(), + listWards: vi.fn(), + resolveDistrict: vi.fn(), + }; + controller = new VnAdminController(mockVnAdmin as any); + }); + + // ── GET /reference/vn-admin/provinces ────────────────────────────── + + describe('listProvinces', () => { + it('delegates to VnAdminService.listProvinces and returns result', async () => { + const provinces = [ + { code: '01', name: 'Hà Nội', type: 'thanh_pho', codename: 'thanh_pho_ha_noi' }, + ]; + mockVnAdmin.listProvinces.mockResolvedValue(provinces); + + const result = await controller.listProvinces(); + + expect(result).toEqual(provinces); + expect(mockVnAdmin.listProvinces).toHaveBeenCalledOnce(); + }); + }); + + // ── GET /reference/vn-admin/provinces/:code/districts ────────────── + + describe('listDistricts', () => { + it('delegates to VnAdminService.listDistricts with the province code', async () => { + const districts = [ + { code: '760', provinceCode: '79', name: 'Quận 1', type: 'quan', codename: 'quan_1' }, + ]; + mockVnAdmin.listDistricts.mockResolvedValue(districts); + + const result = await controller.listDistricts('79'); + + expect(result).toEqual(districts); + expect(mockVnAdmin.listDistricts).toHaveBeenCalledWith('79'); + }); + }); + + // ── GET /reference/vn-admin/districts/:code/wards ────────────────── + + describe('listWards', () => { + it('delegates to VnAdminService.listWards with the district code', async () => { + const wards = [ + { code: '26734', districtCode: '760', name: 'Phường Bến Nghé', type: 'phuong', codename: 'phuong_ben_nghe' }, + ]; + mockVnAdmin.listWards.mockResolvedValue(wards); + + const result = await controller.listWards('760'); + + expect(result).toEqual(wards); + expect(mockVnAdmin.listWards).toHaveBeenCalledWith('760'); + }); + }); + + // ── GET /reference/vn-admin/resolve-district ─────────────────────── + + describe('resolveDistrict', () => { + it('delegates to VnAdminService.resolveDistrict with name and provinceCode', async () => { + const resolution = { + districtCode: '760', + isCurrent: true, + resolvedViaAlias: false, + aliasReason: null, + }; + mockVnAdmin.resolveDistrict.mockResolvedValue(resolution); + + const result = await controller.resolveDistrict('Quận 1', '79'); + + expect(result).toEqual(resolution); + expect(mockVnAdmin.resolveDistrict).toHaveBeenCalledWith('Quận 1', '79'); + }); + + it('delegates without provinceCode when not provided', async () => { + const resolution = { + districtCode: '769', + isCurrent: false, + resolvedViaAlias: true, + aliasReason: 'Sáp nhập', + }; + mockVnAdmin.resolveDistrict.mockResolvedValue(resolution); + + const result = await controller.resolveDistrict('Quận 2', undefined); + + expect(result).toEqual(resolution); + expect(mockVnAdmin.resolveDistrict).toHaveBeenCalledWith('Quận 2', undefined); + }); + + it('throws BadRequestException when name is undefined', async () => { + await expect(controller.resolveDistrict(undefined, undefined)).rejects.toThrow( + BadRequestException, + ); + expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when name is empty string', async () => { + await expect(controller.resolveDistrict('', undefined)).rejects.toThrow( + BadRequestException, + ); + expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when name is whitespace only', async () => { + await expect(controller.resolveDistrict(' ', undefined)).rejects.toThrow( + BadRequestException, + ); + expect(mockVnAdmin.resolveDistrict).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException with correct message', async () => { + await expect(controller.resolveDistrict(undefined, undefined)).rejects.toThrow( + 'Query parameter "name" is required.', + ); + }); + }); + + // ── No auth guard (intentionally public) ─────────────────────────── + + describe('public access (no auth guard)', () => { + it('controller has no UseGuards decorator — all endpoints are public', () => { + // VnAdminController intentionally has no @UseGuards decorator. + // Verify by checking that the controller class metadata does not include + // the NestJS guards metadata key. + const guards = Reflect.getMetadata('__guards__', VnAdminController); + expect(guards).toBeUndefined(); + }); + + it('individual route handlers have no method-level guards', () => { + const methods = ['listProvinces', 'listDistricts', 'listWards', 'resolveDistrict'] as const; + for (const method of methods) { + const guards = Reflect.getMetadata('__guards__', controller[method]); + expect(guards).toBeUndefined(); + } + }); + }); +});