import { BadRequestException, ConflictException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import type { AuthUser } from '../../../common/auth/auth-user'; import { DateTime } from '../../../common/value-objects/date-time/date-time'; import { Status } from '../../../common/value-objects/status/status'; import { BranchesService } from '../../configuration/branches/branches.service'; import { CustomersService } from '../../configuration/customers/customers.service'; import { EmployeesService } from '../../configuration/employees/employees.service'; import { PrivilegesService } from '../../privileges/privileges.service'; import { PackingSlipsService } from '../../sales/packing-slips/packing-slips.service'; import { SalesInvoicesService } from '../../sales/sales-invoices/sales-invoices.service'; import type { Cycle } from '../cycles/cycle'; import { CyclesRepository } from '../cycles/cycles.repository'; import { CompanySettingsService } from '../settings/company-settings.service'; import type { Plan } from './plan'; import { PlansRepository } from './plans.repository'; import { PlansService } from './plans.service'; const MS_PER_DAY = 86_400_000; function todayYmd(): string { return DateTime.fromUnixMs(Math.trunc(Date.now())) .startOfDay() .format() .slice(0, 10); } function nextWeekdayYmd(weekday: string): string { const start = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay(); for (let offset = 0; offset < 8; offset += 1) { const day = DateTime.fromUnixMs( start.value + offset * MS_PER_DAY, ).startOfDay(); if (day.weekdayName() === weekday) { return day.format().slice(0, 10); } } return todayYmd(); } function addDaysYmd(date: string, days: number): string { return DateTime.fromUnixMs( DateTime.create(date).startOfDay().value + days * MS_PER_DAY, ) .startOfDay() .format() .slice(0, 10); } describe('PlansService', () => { let service: PlansService; let plansRepository: jest.Mocked< Pick< PlansRepository, | 'list' | 'findById' | 'findLiveByKey' | 'create' | 'update' | 'replaceDestinations' | 'updateStatus' | 'archive' > >; const cyclesRepository = { listActiveByEmployeePurpose: jest.fn() }; const companySettingsService = { requireCycleStartDate: jest.fn() }; const employeesService = { findById: jest.fn() }; const branchesService = { findById: jest.fn() }; const customersService = { findById: jest.fn() }; const salesInvoicesService = { findById: jest.fn(), markDraftsProcessed: jest.fn(), }; const packingSlipsService = { findById: jest.fn() }; const privilegesService = { checkPermission: jest.fn(), checkAnyPermission: jest.fn(), }; const user: AuthUser = { id: 'user-1', username: 'alice', jti: 'jti-1', isSuperadmin: true, }; const now = DateTime.fromUnixMs(1_700_000_000_000); const geometry = { type: 'LineString' as const, coordinates: [ [106.8, -6.2], [106.9, -6.3], [107.0, -6.4], ] as const, }; const located = { id: 'x', latitude: -6.2, longitude: 106.8 }; const cycle: Cycle = { id: 'cyc-1', employeeId: 'emp-1', purpose: 'sales', cycleNumber: 1, weekdays: [ { id: 'wd-1', weekday: 'monday', startBranchId: 'br-1', endBranchId: 'br-2', startBranch: { id: 'br-1', code: 'B1', name: 'Start' }, endBranch: { id: 'br-2', code: 'B2', name: 'End' }, routeGeometry: geometry, destinations: [ { id: 'cd-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, sortOrder: 0, }, ], }, ], status: Status.create('active'), createdAt: now, updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', employee: { id: 'emp-1', code: 'E1', name: 'Ada' }, createdByUser: { id: 'user-1', username: 'admin' }, updatedByUser: { id: 'user-1', username: 'admin' }, }; const plan: Plan = { id: 'pln-1', employeeId: 'emp-1', purpose: 'sales', date: DateTime.create('2026-01-05'), startBranchId: 'br-1', endBranchId: 'br-2', routeGeometry: geometry, destinations: [ { id: 'pd-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' }, sortOrder: 0, }, ], invoiceIds: [], packingSlipIds: [], invoices: [], packingSlips: [], startBranch: { id: 'br-1', code: 'B1', name: 'Start' }, endBranch: { id: 'br-2', code: 'B2', name: 'End' }, employee: { id: 'emp-1', code: 'E1', name: 'Ada' }, status: Status.create('active'), createdAt: now, updatedAt: now, createdBy: 'user-1', updatedBy: 'user-1', createdByUser: { id: 'user-1', username: 'admin' }, updatedByUser: { id: 'user-1', username: 'admin' }, }; beforeEach(async () => { jest.clearAllMocks(); plansRepository = { list: jest.fn(), findById: jest.fn(), findLiveByKey: jest.fn(), create: jest.fn(), update: jest.fn(), replaceDestinations: jest.fn(), updateStatus: jest.fn(), archive: jest.fn(), }; employeesService.findById.mockResolvedValue({ id: 'emp-1' }); branchesService.findById.mockResolvedValue(located); customersService.findById.mockResolvedValue(located); plansRepository.findLiveByKey.mockResolvedValue(null); plansRepository.create.mockResolvedValue(plan); companySettingsService.requireCycleStartDate.mockResolvedValue( DateTime.create('2026-01-05'), ); cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([cycle]); const moduleRef: TestingModule = await Test.createTestingModule({ providers: [ PlansService, { provide: PlansRepository, useValue: plansRepository }, { provide: CyclesRepository, useValue: cyclesRepository }, { provide: CompanySettingsService, useValue: companySettingsService }, { provide: EmployeesService, useValue: employeesService }, { provide: BranchesService, useValue: branchesService }, { provide: CustomersService, useValue: customersService }, { provide: SalesInvoicesService, useValue: salesInvoicesService }, { provide: PackingSlipsService, useValue: packingSlipsService }, { provide: PrivilegesService, useValue: privilegesService }, ], }).compile(); service = moduleRef.get(PlansService); }); it('rejects packing slips on a sales plan', async () => { await expect( service.create({ employeeId: 'emp-1', purpose: 'sales', date: todayYmd(), startBranchId: 'br-1', endBranchId: 'br-2', customerIds: ['cus-1'], packingSlipIds: ['ps-1'], userId: 'user-1', }), ).rejects.toBeInstanceOf(BadRequestException); }); it('rejects a plan date in the past', async () => { await expect( service.create({ employeeId: 'emp-1', purpose: 'sales', date: '2020-01-01', startBranchId: 'br-1', endBranchId: 'br-2', customerIds: ['cus-1'], userId: 'user-1', }), ).rejects.toMatchObject({ message: 'Date cannot be in the past' }); }); it('rejects invoices that do not belong to selected customers', async () => { salesInvoicesService.findById.mockResolvedValue({ id: 'inv-1', customer: { id: 'cus-2', code: 'C2', name: 'Beta' }, }); await expect( service.create({ employeeId: 'emp-1', purpose: 'sales', date: todayYmd(), startBranchId: 'br-1', endBranchId: 'br-2', customerIds: ['cus-1'], invoiceIds: ['inv-1'], userId: 'user-1', }), ).rejects.toMatchObject({ message: 'Invoice does not belong to a selected customer', }); }); it('keeps an existing past date when the date is unchanged', async () => { plansRepository.findById.mockResolvedValue(plan); plansRepository.update.mockResolvedValue(plan); await service.update( 'pln-1', { date: '2026-01-05', userId: 'user-1' }, user, ); expect(plansRepository.update).toHaveBeenCalled(); }); it('rejects changing a plan date into the past', async () => { plansRepository.findById.mockResolvedValue({ ...plan, date: DateTime.create(todayYmd()), }); await expect( service.update('pln-1', { date: '2020-01-01', userId: 'user-1' }, user), ).rejects.toMatchObject({ message: 'Date cannot be in the past' }); }); it('generate copies a weekday and skips an existing plan', async () => { const from = nextWeekdayYmd('monday'); const to = addDaysYmd(from, 7); plansRepository.findLiveByKey .mockResolvedValueOnce(null) .mockResolvedValueOnce(plan); const result = await service.generate({ employeeId: 'emp-1', purpose: 'sales', from, to, userId: 'user-1', }); expect(result.created).toBe(1); expect(result.skipped).toBeGreaterThan(0); expect(plansRepository.create).toHaveBeenCalledTimes(1); const created = plansRepository.create.mock.calls[0][0]; expect(created.invoiceIds).toEqual([]); expect(created.status.value).toBe('active'); }); it('generate fails when the employee has no cycle', async () => { cyclesRepository.listActiveByEmployeePurpose.mockResolvedValue([]); await expect( service.generate({ employeeId: 'emp-1', purpose: 'sales', from: todayYmd(), to: todayYmd(), userId: 'user-1', }), ).rejects.toBeInstanceOf(BadRequestException); }); it('refuses to remove the last destination', async () => { plansRepository.findById.mockResolvedValue(plan); await expect( service.removeDestination('pln-1', 'pd-1', user), ).rejects.toBeInstanceOf(BadRequestException); }); it('adds a destination without writing a cycle', async () => { plansRepository.findById.mockResolvedValue(plan); plansRepository.replaceDestinations.mockResolvedValue({ ...plan, destinations: [ ...plan.destinations, { id: 'pd-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' }, sortOrder: 1, }, ], }); await service.addDestination('pln-1', 'cus-2', undefined, user); expect(plansRepository.replaceDestinations).toHaveBeenCalled(); expect(cyclesRepository.listActiveByEmployeePurpose).not.toHaveBeenCalled(); }); it('rejects a duplicate destination', async () => { plansRepository.findById.mockResolvedValue(plan); await expect( service.addDestination('pln-1', 'cus-1', undefined, user), ).rejects.toBeInstanceOf(ConflictException); }); });