Files
0pt/tests/contracts/n0d-client.contract.test.ts
admin 2d34cb1629
CI (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (push) Successful in 16s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 23s
CI (SIAX Cloud) / integration (push) Failing after 27s
CI (SIAX Cloud) / integration (pull_request) Failing after 31s
CI (SIAX Cloud) / quality (pull_request) Successful in 42s
CI (SIAX Cloud) / quality (push) Successful in 42s
feat(P-001, P-002): 0pt→N0D quality delegation + internal OpenAPI
2026-09-08 22:38:47 +02:00

210 lines
8.1 KiB
TypeScript

/**
* CONTRACT test for src/clients/n0d-client.ts against N0D's REAL wire
* contract (sax3l/n0d main `src/api/schemas.ts` createJobSchema +
* `src/api/mappers.ts` + job-lifecycle routes, verified 2026-08-31):
*
* POST /v1/n0d/jobs (202) — generic job creation (n0dCreateJob)
* POST /v1/n0d/quality/run (202) — data-quality delegation (n0dRunDataQuality)
* GET /v1/n0d/jobs/:id
* GET /v1/n0d/jobs/:id/result
*
* The pre-2026-08-31 client sent required_node_roles[]/resource_profile/
* output_artifact_type/risk_level — fields N0D's createJobSchema does not
* accept. This test pins the mapped wire shape.
*
* P-001 (owner-seam): 0pt delegates quality to N0D via n0dRunDataQuality
* (POST /v1/n0d/quality/run), NOT the generic createJob. N0D OWNS quality
* execution; 0pt only requests it.
*/
import { describe, it, expect, afterEach } from "vitest";
import {
createN0dClient,
resolveRequiredRole,
resolveResourceRequirements,
type N0dCreateJobInput,
type RunDataQualityInput,
} from "@/src/clients/n0d-client";
import { SiaxError } from "@/src/platform/errors";
import { startContractServer, type ContractServer } from "./contract-test-server";
let server: ContractServer | undefined;
afterEach(async () => {
await server?.close();
server = undefined;
});
function input(overrides: Partial<N0dCreateJobInput> = {}): N0dCreateJobInput {
return {
tenant_id: "t_test",
project_id: "p_test",
job_type: "0pt.quality.run",
capability_id: "0pt.quality.run.v1",
requested_by_app_id: "0pt",
requested_by_actor_id: "svc_0pt",
input_artifact_ids: ["art_in_1"],
output_artifact_type: "quality_result",
requires_backbone: false,
requires_gpu: false,
required_node_roles: ["compute_general"],
resource_profile: "standard",
risk_level: "L2",
correlation_id: "corr-7",
idempotency_key: "idem-job-1",
...overrides,
};
}
const WIRE_JOB = {
job_id: "job_r3al",
job_type: "0pt.quality.run",
tenant_id: "t_test",
project_id: "p_test",
status: "queued",
capability_id: "0pt.quality.run.v1",
created_at: "2026-08-31T12:00:00.000Z",
};
describe("N0dClient wire contract", () => {
it("POSTs the REAL createJob body to /v1/n0d/jobs and accepts the 202", async () => {
server = await startContractServer([
{ method: "POST", path: "/v1/n0d/jobs", status: 202, respond: () => WIRE_JOB },
]);
const client = createN0dClient(server.baseUrl);
const job = await client.createJob(input());
const req = server.requests[0];
expect(req.method).toBe("POST");
expect(req.path).toBe("/v1/n0d/jobs");
expect(req.headers["x-tenant-id"]).toBe("t_test");
const body = req.body as Record<string, unknown>;
// Required fields per createJobSchema.
expect(body.job_type).toBe("0pt.quality.run");
expect(body.capability_id).toBe("0pt.quality.run.v1");
expect(body.tenant_id).toBe("t_test");
expect(body.required_role).toBe("compute_general");
expect(body.resource_requirements).toEqual({ cpu: 2, memory_gb: 4, gpu: 0, disk_gb: 10 });
// Mapped optionals.
expect(body.app_id).toBe("0pt");
expect(body.requested_by).toBe("svc_0pt");
expect(body.priority).toBe("normal");
expect(body.requires_shared_backbone).toBe(false);
expect(body.input).toEqual({ artifact_ids: ["art_in_1"] });
expect(body.output_policy).toEqual({ write_to_st0re: true, artifact_type: "quality_result" });
expect(body.idempotency_key).toBe("idem-job-1");
expect(body.correlation_id).toBe("corr-7");
// Legacy invented fields must NOT be on the wire.
expect(body.required_node_roles).toBeUndefined();
expect(body.resource_profile).toBeUndefined();
expect(body.output_artifact_type).toBeUndefined();
expect(body.risk_level).toBeUndefined();
expect(body.requested_by_app_id).toBeUndefined();
expect(job.job_id).toBe("job_r3al");
expect(job.status).toBe("queued");
});
it("GETs job + result with tenant header (poll path)", async () => {
server = await startContractServer([
{ method: "GET", path: "/v1/n0d/jobs/job_r3al", status: 200, respond: () => ({ ...WIRE_JOB, status: "completed" }) },
{
method: "GET",
path: "/v1/n0d/jobs/job_r3al/result",
status: 200,
respond: () => ({
job_id: "job_r3al",
outcome: "succeeded",
output_artifacts: [{ st0re_artifact_id: "art_out_1", artifact_type: "quality_result" }],
}),
},
]);
const client = createN0dClient(server.baseUrl);
const job = await client.getJob("job_r3al", "t_test");
expect(job.status).toBe("completed");
const result = await client.getJobResult("job_r3al", "t_test");
expect(result.outcome).toBe("succeeded");
expect(result.output_artifacts?.[0]?.st0re_artifact_id).toBe("art_out_1");
for (const req of server.requests) {
expect(req.headers["x-tenant-id"]).toBe("t_test");
}
});
it("role/resource inference: requires_gpu forces ai_gpu + a GPU profile", () => {
const gpuInput = input({ requires_gpu: true, required_node_roles: [], resource_profile: "standard" });
expect(resolveRequiredRole(gpuInput)).toBe("ai_gpu");
expect(resolveResourceRequirements(gpuInput).gpu).toBeGreaterThan(0);
// Explicit values always win.
const explicit = input({
required_role: "storage",
resource_requirements: { cpu: 1, memory_gb: 1, disk_gb: 1 },
});
expect(resolveRequiredRole(explicit)).toBe("storage");
expect(resolveResourceRequirements(explicit)).toEqual({ cpu: 1, memory_gb: 1, disk_gb: 1 });
});
it("maps a 404 (foreign-tenant probe / unknown job) to SiaxError NOT_FOUND", async () => {
server = await startContractServer([]);
const client = createN0dClient(server.baseUrl);
try {
await client.getJob("job_foreign", "t_other");
expect.unreachable("must throw");
} catch (err) {
expect(err).toBeInstanceOf(SiaxError);
expect((err as SiaxError).code).toBe("NOT_FOUND");
}
});
it("P-001: runDataQuality POSTs to /v1/n0d/quality/run with dataset_id + quality_rule_set_id", async () => {
server = await startContractServer([
{ method: "POST", path: "/v1/n0d/quality/run", status: 202, respond: () => WIRE_JOB },
]);
const client = createN0dClient(server.baseUrl);
const runInput: RunDataQualityInput = {
tenant_id: "t_test",
dataset_id: "ds_1",
quality_rule_set_id: "rs_1",
project_id: "p_test",
run_inline: { dataset_csv: "a,b\n1,2", rules: [{ expectation: "expect_column_to_exist", column: "a" }] },
idempotency_key: "dq:ds_1:latest:dqrun_",
};
const job = await client.runDataQuality(runInput);
const req = server.requests[0];
expect(req.method).toBe("POST");
expect(req.path).toBe("/v1/n0d/quality/run");
expect(req.headers["x-tenant-id"]).toBe("t_test");
const body = req.body as Record<string, unknown>;
expect(body.tenant_id).toBe("t_test");
expect(body.dataset_id).toBe("ds_1");
expect(body.quality_rule_set_id).toBe("rs_1");
expect(body.run_inline).toBeDefined();
expect(body.job_type).toBeUndefined();
expect(body.capability_id).toBeUndefined();
expect(body.required_role).toBeUndefined();
expect(body.resource_requirements).toBeUndefined();
expect(body.idempotency_key).toBe("dq:ds_1:latest:dqrun_");
expect(job.job_id).toBe("job_r3al");
});
it("P-001: runDataQuality without run_inline is enqueue-only (no fabricated result)", async () => {
server = await startContractServer([
{ method: "POST", path: "/v1/n0d/quality/run", status: 202, respond: () => WIRE_JOB },
]);
const client = createN0dClient(server.baseUrl);
const job = await client.runDataQuality({
tenant_id: "t_test",
dataset_id: "ds_2",
quality_rule_set_id: "rs_2",
});
expect(job.status).toBe("queued");
expect(job.job_id).toBe("job_r3al");
// The wire body must NOT carry createJob-only fields and must NOT fabricate results.
const body = server.requests[0].body as Record<string, unknown>;
expect(body.run_inline).toBeUndefined();
expect(body.job_type).toBeUndefined();
expect(body.capability_id).toBeUndefined();
});
});