mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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.
254 lines
7.9 KiB
TypeScript
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();
|
|
});
|
|
});
|