mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
RiskStep returned early to a badge as soon as a risk level existed, so the control was unreachable and a mis-assigned level could never be corrected. Both the server and the sibling AssignRiskCard treat risk as correctable until duty is advised off it — completeWithMetadata has no already-completed guard and overwrites metadata.riskLevel. RiskStep was stricter than either. It now keeps the control mounted alongside the assigned badge, offers "Reassign risk", and locks to badge-only once DUTY_TAXES_ADVISED completes. The control also reads the persisted level (it was hardcoded to GREEN, so unhiding it alone would have misreported the assignment), and the T1 gate is skipped once a level exists, since risk cannot be assigned without a closed T1 and stale T1 data must not hide the badge. Correcting a level previously left no record of the old value, who changed it, or when — thin ground for a customer-visible level that may be disputed. assignRisk now appends each decision to metadata.riskHistory: the level, the level it replaced, the timestamp, the user id, and a display name resolved at assignment time so the trail shows a person rather than a UUID. riskLevel still carries the current value and always equals the last entry, so existing consumers are unchanged. History lives on the existing metadata JSONB column, so no migration is needed, and the logic sits in assignRisk rather than the shared completeWithMetadata that adviseDuty and others also use. Re-picking the level already in force is not recorded — it changed nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import type { DataSource } from 'typeorm';
|
|
|
|
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
|
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
|
|
|
type Status = 'PENDING' | 'COMPLETED' | 'SKIPPED';
|
|
|
|
/**
|
|
* Risk assignment is gated on the T1 being closed (catalog order
|
|
* T1_CLOSED → RISK_ASSIGNED): customs cannot rate cargo still under transit.
|
|
*/
|
|
function makeService(t1Status: Status | 'MISSING') {
|
|
const rows = new Map<string, ClearanceMilestone>();
|
|
if (t1Status !== 'MISSING') {
|
|
rows.set('T1_CLOSED', { milestoneCode: 'T1_CLOSED', status: t1Status } as ClearanceMilestone);
|
|
}
|
|
const risk = { milestoneCode: 'RISK_ASSIGNED', status: 'PENDING' } as ClearanceMilestone;
|
|
rows.set('RISK_ASSIGNED', risk);
|
|
|
|
const repo = {
|
|
findOne: jest.fn(({ where }: { where: { milestoneCode: string } }) =>
|
|
Promise.resolve(rows.get(where.milestoneCode) ?? null),
|
|
),
|
|
save: jest.fn((m: ClearanceMilestone) => Promise.resolve(m)),
|
|
};
|
|
const dataSource = { getRepository: () => repo } as unknown as DataSource;
|
|
return { service: new ClearanceMilestoneService(dataSource), repo, risk };
|
|
}
|
|
|
|
describe('ClearanceMilestoneService.assignRisk', () => {
|
|
it('rejects the assignment while the T1 is still open', async () => {
|
|
const { service, repo } = makeService('PENDING');
|
|
|
|
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects the assignment when the booking has no T1_CLOSED milestone', async () => {
|
|
const { service, repo } = makeService('MISSING');
|
|
|
|
await expect(service.assignRisk('b-1', 'GREEN')).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
expect(repo.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('assigns the risk level once the T1 is closed', async () => {
|
|
const { service, risk } = makeService('COMPLETED');
|
|
|
|
const saved = await service.assignRisk('b-1', 'RED', 'user-1');
|
|
|
|
expect(saved.status).toBe('COMPLETED');
|
|
expect(saved.metadata?.riskLevel).toBe('RED');
|
|
expect(risk.triggeredByUserId).toBe('user-1');
|
|
});
|
|
|
|
it('assigns the risk level when the T1 step was skipped', async () => {
|
|
const { service } = makeService('SKIPPED');
|
|
|
|
const saved = await service.assignRisk('b-1', 'YELLOW');
|
|
|
|
expect(saved.status).toBe('COMPLETED');
|
|
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
|
});
|
|
|
|
/**
|
|
* The level is customer-visible and stays correctable until duty is advised,
|
|
* so a changed level must leave a trail rather than overwrite the last one.
|
|
*/
|
|
describe('risk history', () => {
|
|
it('records the first assignment with no previous level', async () => {
|
|
const { service } = makeService('COMPLETED');
|
|
|
|
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
|
|
|
|
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
|
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
|
level: 'RED',
|
|
assignedByUserId: 'user-1',
|
|
assignedBy: 'Abebe K.',
|
|
note: 'initial rating',
|
|
});
|
|
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
|
|
});
|
|
|
|
it('keeps the earlier decision when the level is reassigned', async () => {
|
|
const { service } = makeService('COMPLETED');
|
|
|
|
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
|
|
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
|
|
|
|
expect(saved.metadata?.riskLevel).toBe('GREEN');
|
|
expect(saved.metadata?.riskHistory).toHaveLength(2);
|
|
// The original RED decision survives, with who made it.
|
|
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
|
level: 'RED',
|
|
assignedBy: 'Abebe K.',
|
|
});
|
|
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
|
|
level: 'GREEN',
|
|
previousLevel: 'RED',
|
|
assignedByUserId: 'user-2',
|
|
assignedBy: 'Sara M.',
|
|
note: 'downgraded',
|
|
});
|
|
});
|
|
|
|
it('keeps the whole chain across several reassignments, oldest first', async () => {
|
|
const { service } = makeService('COMPLETED');
|
|
|
|
await service.assignRisk('b-1', 'GREEN');
|
|
await service.assignRisk('b-1', 'YELLOW');
|
|
const saved = await service.assignRisk('b-1', 'RED');
|
|
|
|
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
|
|
'GREEN',
|
|
'YELLOW',
|
|
'RED',
|
|
]);
|
|
});
|
|
|
|
it('does not record a repeat of the level already assigned', async () => {
|
|
const { service } = makeService('COMPLETED');
|
|
|
|
await service.assignRisk('b-1', 'GREEN');
|
|
const saved = await service.assignRisk('b-1', 'GREEN');
|
|
|
|
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
|
});
|
|
|
|
it('always leaves riskLevel equal to the last history entry', async () => {
|
|
const { service } = makeService('COMPLETED');
|
|
|
|
await service.assignRisk('b-1', 'RED');
|
|
const saved = await service.assignRisk('b-1', 'YELLOW');
|
|
|
|
const history = saved.metadata?.riskHistory ?? [];
|
|
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
|
|
});
|
|
});
|
|
});
|