feat(placements): v0.11 P5 learner placement visibility (REQ-11-005) [10/10] — wave 5-2: fetch-stub wire tests + composition source pins
Plan: .ciagent/PLAN.md Phase 5 (waves 5-1/5-2); manager verdict nextcraft#1 (hep, 2026-10-19T19:29Z). P5 vertical slice per the binding wave order: store reads -> routes -> web wiring -> tests in the same wave.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* v0.11 P5 (REQ-11-005, D-104/G-322): the learner placement-status
|
||||
* client + panel wiring. Fetch stubs pin the wire shapes + the query
|
||||
* composition (the billing-entitlement template); source pins cover
|
||||
* the panel's mine-read-first composition, the honest state arms, and
|
||||
* the A-304 badge honesty (the G-312 discipline — no render harness
|
||||
* in-tree, none may be added).
|
||||
*/
|
||||
|
||||
const QS = `?t=${Date.now()}-${Math.random()}`;
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
|
||||
function web(path: string): string {
|
||||
return fileURLToPath(new URL(path, root));
|
||||
}
|
||||
|
||||
const STATUS_PAYLOAD = {
|
||||
application: {
|
||||
id: "app-1",
|
||||
posting_id: "post-1",
|
||||
learner_id: "pilot-learner",
|
||||
status: "in_review",
|
||||
created_at: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
application_id: "app-1",
|
||||
placement_id: null,
|
||||
event_type: "application.in_review",
|
||||
actor_type: "employer",
|
||||
actor_ref: "employer:emp-test",
|
||||
payload: {},
|
||||
prev_hash: "0".repeat(64),
|
||||
hash: "a".repeat(64),
|
||||
created_at: "2026-10-21T00:01:00Z",
|
||||
},
|
||||
],
|
||||
placement: null,
|
||||
};
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
function stubFetch(handler: () => Response): { calls: Call[]; restore: () => void } {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Call[] = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
});
|
||||
return handler();
|
||||
}) as typeof fetch;
|
||||
return { calls, restore: () => { globalThis.fetch = realFetch; } };
|
||||
}
|
||||
|
||||
const ok = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
test("getMyApplication: GETs the mine route with the (posting, learner) pair — G-322", async () => {
|
||||
const { calls, restore } = stubFetch(() =>
|
||||
ok({ application_id: "app-1", status: "submitted" }),
|
||||
);
|
||||
try {
|
||||
const { getMyApplication } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const mine = await getMyApplication("post-1", "pilot-learner");
|
||||
assert.equal(mine.application_id, "app-1");
|
||||
assert.equal(mine.status, "submitted");
|
||||
const url = new URL(calls[0].url, "http://localhost");
|
||||
assert.equal(url.pathname, "/v1/marketplace/applications/mine");
|
||||
assert.equal(url.searchParams.get("posting_id"), "post-1");
|
||||
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
|
||||
assert.equal(calls[0].method, undefined, "the discovery read is a GET");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("getMyApplication: no application → EngineError 404 (the apply-CTA arm)", async () => {
|
||||
const { restore } = stubFetch(
|
||||
() => new Response(JSON.stringify({ detail: "none" }), { status: 404 }),
|
||||
);
|
||||
try {
|
||||
const { getMyApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await assert.rejects(getMyApplication("post-1", "pilot-learner"), (err: unknown) =>
|
||||
err instanceof EngineError && err.status === 404,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("getLearnerApplicationStatus: GETs the status read with the learner query", async () => {
|
||||
const { calls, restore } = stubFetch(() => ok(STATUS_PAYLOAD));
|
||||
try {
|
||||
const { getLearnerApplicationStatus } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const status = await getLearnerApplicationStatus("app-1", "pilot-learner");
|
||||
assert.equal(status.application.status, "in_review");
|
||||
assert.equal(status.events.length, 1);
|
||||
assert.equal(status.events[0].event_type, "application.in_review");
|
||||
assert.equal(status.placement, null);
|
||||
const url = new URL(calls[0].url, "http://localhost");
|
||||
assert.equal(url.pathname, "/v1/marketplace/applications/app-1");
|
||||
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("withdrawApplication: POSTs the withdraw body — the learner's only edit (D-104)", async () => {
|
||||
const { calls, restore } = stubFetch(() =>
|
||||
ok({ application_id: "app-1", posting_id: "post-1", status: "withdrawn" }),
|
||||
);
|
||||
try {
|
||||
const { withdrawApplication } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const confirmation = await withdrawApplication("app-1", "pilot-learner");
|
||||
assert.equal(confirmation.status, "withdrawn");
|
||||
const url = new URL(calls[0].url, "http://localhost");
|
||||
assert.equal(url.pathname, "/v1/marketplace/applications/app-1/withdraw");
|
||||
assert.equal(calls[0].method, "POST");
|
||||
assert.deepEqual(calls[0].body, { learner_id: "pilot-learner" });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("withdrawApplication: terminal → EngineError 409 (the honest matrix refusal)", async () => {
|
||||
const { restore } = stubFetch(
|
||||
() => new Response(JSON.stringify({ detail: "matrix" }), { status: 409 }),
|
||||
);
|
||||
try {
|
||||
const { withdrawApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await assert.rejects(withdrawApplication("app-1", "pilot-learner"), (err: unknown) =>
|
||||
err instanceof EngineError && err.status === 409,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("getLearnerApplicationStatus: foreign → EngineError 403 (the not-your-application arm)", async () => {
|
||||
const { restore } = stubFetch(
|
||||
() => new Response(JSON.stringify({ detail: "foreign" }), { status: 403 }),
|
||||
);
|
||||
try {
|
||||
const { getLearnerApplicationStatus, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await assert.rejects(
|
||||
getLearnerApplicationStatus("app-1", "pilot-learner"),
|
||||
(err: unknown) => err instanceof EngineError && err.status === 403,
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("pin: the panel composes mine-read first — never client-persisted application ids (G-322)", async () => {
|
||||
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
||||
assert.ok(/getMyApplication\(/.test(src), "the mine-read drives discovery");
|
||||
assert.ok(/getLearnerApplicationStatus\(/.test(src), "the status read drives the panel");
|
||||
assert.ok(/withdrawApplication\(/.test(src), "the withdraw CTA dispatches through the client");
|
||||
assert.ok(
|
||||
!/localStorage|sessionStorage/.test(src),
|
||||
"no client-persisted application ids",
|
||||
);
|
||||
// mine 404 → the apply CTA (the honest absence arm)
|
||||
assert.ok(
|
||||
/mineErr instanceof EngineError && mineErr\.status === 404/.test(src),
|
||||
"the 404 arm falls through to the apply CTA",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin: the seven D-099 states render honestly (labels + withdraw gate + terminal/403 copy)", async () => {
|
||||
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
||||
for (const state of [
|
||||
"'submitted'",
|
||||
"'in_review'",
|
||||
"'interview'",
|
||||
"'offer'",
|
||||
"'hired'",
|
||||
"'rejected'",
|
||||
"'withdrawn'",
|
||||
]) {
|
||||
assert.ok(src.includes(state), `the ${state} arm renders a label`);
|
||||
}
|
||||
assert.ok(
|
||||
/WITHDRAWABLE_STATUSES/.test(src),
|
||||
"the withdraw CTA gates on non-terminal sources (D-099)",
|
||||
);
|
||||
assert.ok(/status === 409/.test(src), "the 409 arm renders the honest terminal copy");
|
||||
assert.ok(
|
||||
/This application belongs to another learner\./.test(src),
|
||||
"the foreign-403 copy renders",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin: history rows + the placement-confirmation render from the status payload", async () => {
|
||||
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
||||
assert.ok(/appStatus\.events\.map\(/.test(src), "history rows render from events[]");
|
||||
assert.ok(
|
||||
/appStatus\.placement &&/.test(src),
|
||||
"the placement confirmation renders when hired",
|
||||
);
|
||||
assert.ok(
|
||||
/role_title/.test(src) && /start_date/.test(src),
|
||||
"the placement payload fields render",
|
||||
);
|
||||
assert.ok(
|
||||
/Placement recorded/.test(src),
|
||||
"the placement copy says RECORDED (G-328 — never a verification claim)",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user