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
210 lines
8.1 KiB
TypeScript
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();
|
|
});
|
|
});
|