- Reformatted import statements and object properties across multiple files to enhance code clarity and maintainability. - Ensured consistent indentation and line breaks in test specifications and service implementations, improving overall code structure.
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|