fix(release): withhold unqualified CV review and record gates

This commit is contained in:
cesnimda
2026-10-08 18:40:07 +02:00
parent 9c91575079
commit fe045b9e01
11 changed files with 544 additions and 5 deletions
@@ -128,3 +128,58 @@ test("bound CV proposals use real owned saves and reject fabricated or stale out
expect(state.calls.some((call: { path: string; body: { output_mode?: string } }) => call.path === "/cv/rewrite" && call.body.output_mode === "cv_blocks_json")).toBe(true);
await testInfo.attach("synthetic-provider-contract-calls", { body: JSON.stringify(state, null, 2), contentType: "application/json" });
});
test("Production withholds CV review before provider calls or content writes while existing AI remains available", async ({ request, playwright }) => {
const productionUrl = `${process.env.CV_AI_READINESS_PRODUCTION_API_URL}/api`;
const api = await playwright.request.newContext();
try {
const login = await api.post(`${productionUrl}/auth/login`, { data: { email: "cv-ai-readiness@example.test", password } });
expect(login.status()).toBe(200);
// This is an isolated loopback API behind an HTTPS canonical origin. Keep its Secure cookies
// unchanged and explicitly transport them across the test-only internal HTTP connection.
const cookies = login.headersArray().filter(header => header.name.toLowerCase() === "set-cookie").map(header => header.value.split(";")[0]);
const headers: Record<string, string> = { Cookie: cookies.join("; "), "X-CSRF-TOKEN": cookies.find(cookie => cookie.startsWith("XSRF-TOKEN="))!.slice("XSRF-TOKEN=".length) };
const me = await api.get(`${productionUrl}/auth/me`, { headers });
expect(me.status()).toBe(200);
const account = await me.json();
expect(account.entitlements.ai).toBe(true);
headers["X-Session-Context"] = account.sessionContext;
expect((await request.post(`${providerUrl}/fixture/reset`)).status()).toBe(200);
const profile = { version: "1", contact: { fullName: "Production Gate Fixture", headline: source }, summary: [source] };
expect((await api.put(`${productionUrl}/career/profile`, { headers, data: { profile } })).status()).toBe(200);
const created = await api.post(`${productionUrl}/cv/variants`, { headers, data: { name: "Withheld AI review", settings: { themeId: "minimal", language: "en", sections: [{ key: "summary" }] } } });
expect(created.status()).toBe(200);
const cv = await created.json();
const snapshot = async () => {
const responses = await Promise.all([
api.get(`${productionUrl}/cv/variants/${cv.id}`, { headers }), api.get(`${productionUrl}/cv/variants/${cv.id}/versions`, { headers }),
api.get(`${productionUrl}/career/profile`, { headers }), api.get(`${productionUrl}/career/profile/versions`, { headers }),
]);
for (const response of responses) expect(response.status()).toBe(200);
return Promise.all(responses.map(response => response.json()));
};
const before = await snapshot();
for (const action of ["improve", "document-improve"]) {
const response = await api.post(`${productionUrl}/cv/ai/assist`, { headers, data: {
variantId: cv.id, expectedVersion: cv.version, action, language: "en",
...(action.startsWith("document-") ? {} : { targetId: "section:summary", text: source }),
} });
expect(response.status()).toBe(503);
expect(await response.json()).toEqual({ code: "cv_ai_unavailable" });
}
const applied = await api.post(`${productionUrl}/cv/variants/${cv.id}/ai/apply`, { headers, data: { proposalToken: "unused-proposal", result: rewrite } });
expect(applied.status()).toBe(503);
expect(await applied.json()).toEqual({ code: "cv_ai_unavailable" });
expect(await snapshot()).toEqual(before);
expect(await (await request.get(`${providerUrl}/fixture/state`)).json()).toEqual({ mode: "supported", counts: { rewrite: 0, evaluate: 0 }, calls: [] });
expect((await api.get(`${productionUrl}/jobapplications/1/ai/modules`, { headers })).status()).toBe(200);
const legacy = await api.post(`${productionUrl}/profile-cv/rewrite-section`, { headers, data: { sourceText: source, language: "en" } });
expect(legacy.status()).toBe(200);
expect((await legacy.json()).text).toBe(rewrite);
expect(await snapshot()).toEqual(before);
const state = await (await request.get(`${providerUrl}/fixture/state`)).json();
expect(state.counts).toEqual({ rewrite: 1, evaluate: 0 });
} finally { await api.dispose(); }
});
+22 -1
View File
@@ -6,12 +6,14 @@ import path from "node:path";
// Separate real-HTTP contract suite. Neither process uses the normal browser-suite ports.
const apiUrl = "http://127.0.0.1:15313";
const providerUrl = "http://127.0.0.1:18133";
const productionApiUrl = "http://127.0.0.1:15314";
const dataRoot = process.env.CV_AI_READINESS_DATA_ROOT ??= fs.mkdtempSync(path.join(os.tmpdir(), "jobtracker-cv-ai-readiness-"));
process.env.CV_AI_READINESS_API_URL = apiUrl;
process.env.CV_AI_READINESS_PROVIDER_URL = providerUrl;
process.env.CV_AI_READINESS_PRODUCTION_API_URL = productionApiUrl;
const quote = (value: string) => `"${value.replaceAll('"', '\\"')}"`;
export default defineConfig({
const config = defineConfig({
testDir: "./ai-tests",
testMatch: "cv-ai-readiness.spec.ts",
workers: 1,
@@ -61,3 +63,22 @@ export default defineConfig({
},
],
});
const webServers = Array.isArray(config.webServer) ? config.webServer : [config.webServer!];
const backend = webServers[1];
export default defineConfig({
...config,
webServer: [...webServers, {
...backend,
// Launch settings otherwise override the Production environment with Development.
command: backend.command.replace(" run ", " run --no-launch-profile ").replace(apiUrl, productionApiUrl),
url: `${productionApiUrl}/ready`,
env: {
...backend.env,
ASPNETCORE_ENVIRONMENT: "Production",
App__PublicBaseUrl: productionApiUrl.replace("http:", "https:"),
Data__Root: path.join(dataRoot, "production"),
ConnectionStrings__JobTracker: `Data Source=${path.join(dataRoot, "production", "readiness.sqlite")}`,
},
}],
});