Files
edr-platform/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts
Nathnael 7c5d96795c feat(freight): track onboarding-phase edit history for companies
Ticket #238 — pre-approval edits and document uploads write straight
to the live company row with no approval gate and, until now, no
trace. Adds an append-only company_revisions log (diffed field
changes, document uploads) recorded from updateProfile and
uploadCompanyDocuments, exposed via GET /companies/:id/revisions and
shown as "Version history" on the backoffice customer detail page.
2026-07-31 11:45:09 +00:00

254 lines
7.9 KiB
TypeScript

import { BadRequestException } from "@nestjs/common";
import { CompaniesService } from "./companies.service";
import { CompanyStatus } from "./entities/company.entity";
import { ProfileType } from "./entities/company-profile.entity";
import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.constants";
/**
* EDRFREIGHT-358: a company that names a Power of Attorney must have the DARS
* delegation paper on file. The rule used to live only in the onboarding
* wizard's completion check, so every other write that could break the pairing
* — saving PoA details, deleting the paper, picking up the forwarder role —
* went unguarded. These cover those writes.
*/
interface Ctx {
attributes: Record<string, unknown>;
files: { id: string; code: string; reviewStatus?: string | null }[];
profileTypes: ProfileType[];
status: CompanyStatus;
pendingSnapshot: Record<string, unknown> | null;
}
const POA = { poaName: "Abebe", poaEmail: "a@b.com", poaPhone: "+251911000000" };
/**
* The forwarder role is gated on Fayda-verified identities as well as on the
* delegation paper. These tests are about the paper, so they run against a
* company whose identities are already verified — the identity rule itself is
* covered in companies.fayda-identity.spec.ts.
*/
const VERIFIED_IDENTITIES = {
ownerFaydaSub: "owner-sub",
poaFaydaSub: "poa-sub",
};
function makeService(overrides: Partial<Ctx> = {}) {
const ctx: Ctx = {
attributes: {},
files: [],
profileTypes: [ProfileType.importer],
status: CompanyStatus.Pending,
pendingSnapshot: null,
...overrides,
};
const company = () => ({
id: "company-1",
status: ctx.status,
attributes: ctx.attributes,
companyProfiles: ctx.profileTypes.map((type, i) => ({
id: `profile-${i}`,
type,
})),
type: "customer",
});
const deps = {
companiesRepo: {
findById: jest.fn(async () => company()),
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
ctx.attributes = (patch.attributes ??
ctx.attributes) as Record<string, unknown>;
return company();
}),
findByTin: jest.fn(async () => null),
},
companyProfilesRepo: {
findByCompanyId: jest.fn(async () =>
ctx.profileTypes.map((type, i) => ({ id: `profile-${i}`, type })),
),
findByType: jest.fn(async (_id: string, type: ProfileType) =>
ctx.profileTypes.includes(type) ? { id: "existing", type } : null,
),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "new",
...row,
})),
},
changeRequestRepo: {
findPendingByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findLatestOpenByCompanyId: jest.fn(async () =>
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
),
findByCompanyId: jest.fn(async () => []),
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "cr-1",
...row,
})),
update: jest.fn(async () => ({ id: "cr-1" })),
},
revisionRepo: {
create: jest.fn(async (row: Record<string, unknown>) => ({
id: "rev-1",
...row,
})),
findByCompanyId: jest.fn(async () => []),
},
profilesRepo: {
findByCompanyId: jest.fn(async () => []),
findByUserId: jest.fn(async () => ({
id: "external-1",
companyId: "company-1",
company: company(),
onboardingCompleted: false,
})),
},
filesService: {
findByResource: jest.fn(async () => ctx.files),
findById: jest.fn(async (id: string) =>
ctx.files.find((f) => f.id === id)
? {
...ctx.files.find((f) => f.id === id),
resource: "companies",
resourceId: "company-1",
name: "dars.pdf",
}
: null,
),
remove: jest.fn(async () => undefined),
},
companyNotifier: { changeRequestSubmitted: jest.fn() },
};
const service = new CompaniesService(
deps.companiesRepo as never,
deps.companyProfilesRepo as never,
deps.changeRequestRepo as never,
deps.revisionRepo as never,
deps.profilesRepo as never,
{} as never,
deps.filesService as never,
{} as never,
{} as never,
deps.companyNotifier as never,
{} as never,
{} as never,
);
// getCompanyInfoByUserId does its own lookups; the stubs above are enough for
// the PoA paths, so short-circuit it rather than mock the whole graph.
jest
.spyOn(service, "getCompanyInfoByUserId")
.mockImplementation(
async () =>
({ profile: { id: "external-1" }, company: company() }) as never,
);
return { service, ctx, deps };
}
const paper = (reviewStatus: string | null = null) => ({
id: "file-1",
code: POA_DELEGATION_FILE_KEY,
reviewStatus,
});
describe("PoA delegation paper is enforced wherever PoA state changes", () => {
it("rejects PoA details saved with no paper on file", async () => {
const { service } = makeService();
await expect(
service.updateProfile("user-1", POA as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("accepts PoA details once the paper is on file", async () => {
const { service } = makeService({ files: [paper()] });
await expect(
service.updateProfile("user-1", POA as never),
).resolves.toBeDefined();
});
it("rejects a paper the reviewer sent back for correction", async () => {
const { service } = makeService({ files: [paper("change_requested")] });
await expect(
service.updateProfile("user-1", POA as never),
).rejects.toBeInstanceOf(BadRequestException);
});
it("leaves edits that don't touch the PoA alone", async () => {
// A company carrying legacy details must not be locked out of every other
// field until it produces a paper.
const { service } = makeService({ attributes: { ...POA }, files: [] });
await expect(
service.updateProfile("user-1", { companyEmail: "x@y.com" } as never),
).resolves.toBeDefined();
});
it("refuses to remove the paper while the PoA is still named", async () => {
const { service } = makeService({
attributes: { ...POA },
files: [paper()],
});
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).rejects.toBeInstanceOf(BadRequestException);
});
it("allows removing the paper once the PoA has been cleared", async () => {
const { service } = makeService({ attributes: {}, files: [paper()] });
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).resolves.toBeDefined();
});
it("judges the removal against a staged clear, not the live row", async () => {
// An Active company's edits are staged for review rather than written, so
// the live attributes still carry the PoA the customer just cleared.
const { service } = makeService({
status: CompanyStatus.Active,
attributes: { ...POA },
pendingSnapshot: { poaName: "", poaEmail: "", poaPhone: "" },
files: [paper()],
});
await expect(
service.removePoaDelegationLetter("user-1", "file-1"),
).resolves.toBeDefined();
});
it("refuses the forwarder role to a company with no PoA", async () => {
const { service } = makeService({ attributes: { ...VERIFIED_IDENTITIES } });
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it("grants the forwarder role once PoA details and paper are both in place", async () => {
const { service } = makeService({
attributes: { ...POA, ...VERIFIED_IDENTITIES },
files: [paper()],
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
});