mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
A contractor-org user (resolved via team_members → team → org, #408) now sees only their own slice of a project: work orders issued to their organisation, scoped KPI tiles, the workstreams they are assigned to, and a permission-driven "What you can do" panel. The scoping is enforced server-side, in one place. `workOrderOrganisationScope` (@/lib/projects/authz) is the single definition of whose orders a caller may see — null for internal/client (the whole programme), a contractor's own orgs otherwise, never another org's. The work-orders route handler resolves that scope and passes it to `loadWorkOrderPage`, which pushes it into SQL as an `AND pwc.organisation_id IN (…)`. Because it is a separate predicate, it composes with — and can never be widened by — the caller's own contractor filter: a contractor asking for another org's orders gets an empty page, not a leak. - Reuses the #419 admin table component and derivations verbatim; only the server-seeded page and (cosmetically) the filter options are scoped. - The contractor per-project view replaces the programme dashboard for that role, deriving KPIs from the same `summariseDashboard` fold over organisation-scoped groups so tiles and table reconcile. - Row action links to the work-order detail route (#422, built in parallel). - Internal/client callers are unaffected: their scope is null, so the query is unchanged from #419. Tests: route-handler-level and query-construction tests prove a contractor can never retrieve another org's orders (including when they filter by one); pure authz tests for scope resolution and capability summary; a Cypress contractor-login-sees-scoped-list spec. Typecheck, lint and the full unit suite (1180) are green. See ADR-0023. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
200 lines
7.4 KiB
TypeScript
200 lines
7.4 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { PgDialect } from "drizzle-orm/pg-core";
|
|
|
|
// The db module is stubbed *before* it is imported, so no `pg.Pool` is ever
|
|
// constructed and this suite cannot open a connection. Everything under test
|
|
// here is query construction, not query execution.
|
|
vi.mock("@/app/db/db", () => ({
|
|
db: {
|
|
execute: () => {
|
|
throw new Error("dashboardRepository.test must not execute queries");
|
|
},
|
|
},
|
|
pool: {},
|
|
}));
|
|
|
|
import {
|
|
buildApplicableEvidencePairs,
|
|
buildWorkOrderGroupsQuery,
|
|
organisationScopeCondition,
|
|
overdueOf,
|
|
parseProjectId,
|
|
type WorkOrderGroup,
|
|
} from "./dashboardRepository";
|
|
import {
|
|
buildStageLadders,
|
|
type EvidenceRequirementEntry,
|
|
type StageLadderEntry,
|
|
} from "@/lib/projects/derivations";
|
|
|
|
const dialect = new PgDialect();
|
|
const render = (query: ReturnType<typeof buildWorkOrderGroupsQuery>) =>
|
|
dialect.sqlToQuery(query);
|
|
|
|
const WINDOWS = 10n;
|
|
const STAGES: StageLadderEntry[] = [
|
|
{ id: 101n, projectWorkstreamId: WINDOWS, order: 1 },
|
|
{ id: 102n, projectWorkstreamId: WINDOWS, order: 2 },
|
|
{ id: 103n, projectWorkstreamId: WINDOWS, order: 3 },
|
|
];
|
|
const LADDERS = buildStageLadders(STAGES);
|
|
const ASOF = new Date("2026-07-21T09:30:00Z");
|
|
|
|
describe("buildWorkOrderGroupsQuery", () => {
|
|
it("binds the project id and the asOf day as parameters, not as literals", () => {
|
|
const { sql, params } = render(buildWorkOrderGroupsQuery(1234n, ASOF, []));
|
|
expect(params).toContain(1234n);
|
|
expect(params).toContain("2026-07-21");
|
|
// The day the page measured against, never the database's own clock — the
|
|
// two could disagree across midnight.
|
|
expect(sql).not.toContain("CURRENT_DATE");
|
|
});
|
|
|
|
it("groups in SQL rather than selecting work-order rows", () => {
|
|
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, []));
|
|
expect(sql).toContain(
|
|
"GROUP BY workstream_id, contractor_organisation_id, contractor_name, stage_id",
|
|
);
|
|
expect(sql).toContain("count(*)");
|
|
});
|
|
|
|
it("left-joins the contractor assignment so unassigned orders survive", () => {
|
|
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, []));
|
|
expect(sql).toContain("LEFT JOIN project_workstream_contractor");
|
|
});
|
|
|
|
it("counts past-forecast work orders without deciding overdue in SQL", () => {
|
|
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, []));
|
|
expect(sql).toContain("AS past_forecast");
|
|
// Terminal-ness is a ladder question; the query must not attempt it.
|
|
expect(sql).not.toMatch(/max\s*\(\s*"?order"?\s*\)/i);
|
|
});
|
|
|
|
it("emits a well-typed empty CTE when nothing is required, not empty VALUES", () => {
|
|
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, []));
|
|
expect(sql).toContain("WHERE false");
|
|
expect(sql).not.toMatch(/VALUES\s*\)/);
|
|
});
|
|
|
|
it("inlines applicable (stage, requirement) pairs as bigint literals", () => {
|
|
const { sql } = render(
|
|
buildWorkOrderGroupsQuery(1n, ASOF, [
|
|
{ stageId: 102n, requirementId: 7n },
|
|
{ stageId: 103n, requirementId: 8n },
|
|
]),
|
|
);
|
|
expect(sql).toContain("(102::bigint, 7::bigint)");
|
|
expect(sql).toContain("(103::bigint, 8::bigint)");
|
|
expect(sql).toContain("AS v(stage_id, requirement_id)");
|
|
});
|
|
|
|
it("rejects an invalid asOf rather than issuing a query with a null date", () => {
|
|
expect(() => buildWorkOrderGroupsQuery(1n, new Date("nope"), [])).toThrow();
|
|
});
|
|
|
|
describe("contractor organisation scope (issue #421)", () => {
|
|
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
|
|
|
|
it("adds no scope predicate when unscoped (internal/client KPIs)", () => {
|
|
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, [], null));
|
|
expect(sql).not.toContain("pwc.organisation_id IN");
|
|
});
|
|
|
|
it("narrows the aggregate to a contractor's own organisations", () => {
|
|
const { sql, params } = render(
|
|
buildWorkOrderGroupsQuery(1n, ASOF, [], [CONTRACTOR]),
|
|
);
|
|
expect(sql).toContain("pwc.organisation_id IN (");
|
|
expect(params).toContain(CONTRACTOR);
|
|
});
|
|
|
|
it("counts nobody rather than everybody for an empty scope", () => {
|
|
const { sql } = render(buildWorkOrderGroupsQuery(1n, ASOF, [], []));
|
|
// Belt and braces: `... AND false` returns zero rows, so a contractor
|
|
// resolved to no org sees empty KPIs, never the whole programme's.
|
|
expect(sql).toContain("false");
|
|
expect(sql).not.toContain("pwc.organisation_id IN");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("organisationScopeCondition", () => {
|
|
const CONTRACTOR = "22222222-2222-2222-2222-222222222222";
|
|
|
|
it("returns null when unscoped, so no predicate is added", () => {
|
|
expect(organisationScopeCondition(null)).toBeNull();
|
|
});
|
|
|
|
it("returns an IN predicate binding each org id for a non-empty scope", () => {
|
|
const condition = organisationScopeCondition([CONTRACTOR]);
|
|
const { sql, params } = dialect.sqlToQuery(condition!);
|
|
expect(sql).toContain("pwc.organisation_id IN (");
|
|
expect(sql).toContain("::uuid");
|
|
expect(params).toContain(CONTRACTOR);
|
|
});
|
|
|
|
it("returns a literal false for an empty scope — nobody, not everybody", () => {
|
|
const condition = organisationScopeCondition([]);
|
|
const { sql } = dialect.sqlToQuery(condition!);
|
|
expect(sql).toContain("false");
|
|
});
|
|
});
|
|
|
|
describe("buildApplicableEvidencePairs", () => {
|
|
const REQUIREMENTS: EvidenceRequirementEntry[] = [
|
|
{ id: 1n, projectWorkstreamId: WINDOWS, projectWorkstreamStageId: null, required: true },
|
|
{ id: 2n, projectWorkstreamId: WINDOWS, projectWorkstreamStageId: 103n, required: true },
|
|
{ id: 3n, projectWorkstreamId: WINDOWS, projectWorkstreamStageId: null, required: false },
|
|
];
|
|
|
|
it("expands the derivation rule into one pair per applicable stage", () => {
|
|
const pairs = buildApplicableEvidencePairs(LADDERS, STAGES, REQUIREMENTS);
|
|
expect(pairs).toContainEqual({ stageId: 101n, requirementId: 1n });
|
|
expect(pairs).toContainEqual({ stageId: 103n, requirementId: 1n });
|
|
// The stage-103 requirement applies only from stage 103 onwards.
|
|
expect(pairs).toContainEqual({ stageId: 103n, requirementId: 2n });
|
|
expect(pairs).not.toContainEqual({ stageId: 101n, requirementId: 2n });
|
|
expect(pairs).not.toContainEqual({ stageId: 102n, requirementId: 2n });
|
|
});
|
|
|
|
it("never emits an optional requirement", () => {
|
|
const pairs = buildApplicableEvidencePairs(LADDERS, STAGES, REQUIREMENTS);
|
|
expect(pairs.every((p) => p.requirementId !== 3n)).toBe(true);
|
|
});
|
|
|
|
it("produces nothing when no requirements are configured", () => {
|
|
expect(buildApplicableEvidencePairs(LADDERS, STAGES, [])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("overdueOf", () => {
|
|
const group = (overrides: Partial<WorkOrderGroup>): WorkOrderGroup => ({
|
|
workstreamId: WINDOWS,
|
|
contractorOrganisationId: null,
|
|
contractorName: null,
|
|
stageId: 102n,
|
|
orders: 0,
|
|
pastForecast: 0,
|
|
requiredEvidence: 0,
|
|
satisfiedEvidence: 0,
|
|
ordersMissingEvidence: 0,
|
|
...overrides,
|
|
});
|
|
|
|
it("gates the SQL count on the ladder's terminal rule", () => {
|
|
expect(overdueOf(LADDERS, group({ stageId: 102n, pastForecast: 5 }))).toBe(5);
|
|
expect(overdueOf(LADDERS, group({ stageId: 103n, pastForecast: 5 }))).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("parseProjectId", () => {
|
|
it("accepts a positive integer segment", () => {
|
|
expect(parseProjectId("42")).toBe(42n);
|
|
});
|
|
|
|
it("rejects anything that is not a positive id", () => {
|
|
for (const bad of ["0", "-1", "1.5", "abc", "", "1; DROP TABLE work_order"]) {
|
|
expect(parseProjectId(bad)).toBeNull();
|
|
}
|
|
});
|
|
});
|