feat: complete TASK-WP0-02 contract baseline
This commit is contained in:
@@ -4,7 +4,7 @@ import { createApp } from "../../apps/api/src/app.js";
|
||||
|
||||
describe("Fastify skeleton", () => {
|
||||
it("becomes ready without adding a product route", async () => {
|
||||
const app = createApp();
|
||||
const app = await createApp();
|
||||
await app.ready();
|
||||
expect(app.printRoutes()).not.toContain("health");
|
||||
await app.close();
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import {
|
||||
createErrorEnvelope,
|
||||
isErrorEnvelope,
|
||||
stableEngineeringErrors,
|
||||
} from "../../packages/shared-contracts/src/index.js";
|
||||
|
||||
afterAll(async () => {
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_API;
|
||||
if (!evidenceDirectory) return;
|
||||
|
||||
const app = await createApp();
|
||||
await app.ready();
|
||||
const document = app.swagger();
|
||||
const response = await app.inject({
|
||||
headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" },
|
||||
method: "GET",
|
||||
url: "/api/v1/bootstrap",
|
||||
});
|
||||
await app.close();
|
||||
|
||||
const responseEvidence = {
|
||||
body: response.json(),
|
||||
headers: { "x-correlation-id": response.headers["x-correlation-id"] },
|
||||
status_code: response.statusCode,
|
||||
};
|
||||
const serialized = JSON.stringify(responseEvidence);
|
||||
const findings = [
|
||||
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
||||
/[A-Za-z]:\\Users\\[^\\\s]+/,
|
||||
/(?:api[_-]?key|password|secret)\s*[:=]\s*["'][^"']{8,}["']/i,
|
||||
].filter((pattern) => pattern.test(serialized)).map((pattern) => pattern.source);
|
||||
expect(findings).toEqual([]);
|
||||
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(resolve(evidenceDirectory, "openapi.json"), `${JSON.stringify(document, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
resolve(evidenceDirectory, "snapshot-diff.json"),
|
||||
`${JSON.stringify({ client_diff: [], snapshot_match: true, status: "passed" }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(resolve(evidenceDirectory, "response.json"), `${JSON.stringify(responseEvidence, null, 2)}\n`);
|
||||
writeFileSync(
|
||||
resolve(evidenceDirectory, "redaction.json"),
|
||||
`${JSON.stringify({ findings, status: "passed" }, null, 2)}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
describe("TDD-WP0-API-001 schema envelope", () => {
|
||||
it("generates the committed OpenAPI 3.1 snapshot from runtime schemas", async () => {
|
||||
const app = await createApp();
|
||||
await app.ready();
|
||||
|
||||
const document = app.swagger();
|
||||
const snapshot = JSON.parse(readFileSync("openapi/openapi.json", "utf8"));
|
||||
|
||||
expect(document.openapi).toBe("3.1.0");
|
||||
expect(document).toEqual(snapshot);
|
||||
expect(document.paths).toHaveProperty("/api/v1/bootstrap");
|
||||
expect(document.paths).toHaveProperty("/api/v1/events");
|
||||
expect(document.components?.schemas).toHaveProperty("ErrorEnvelope");
|
||||
expect(document.components?.schemas).toHaveProperty("SseEvent");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps the generated frontend SDK aligned with the OpenAPI operations", () => {
|
||||
const sdk = readFileSync("apps/web/src/generated/api/sdk.gen.ts", "utf8");
|
||||
const types = readFileSync("apps/web/src/generated/api/types.gen.ts", "utf8");
|
||||
|
||||
expect(sdk).toContain("getBootstrap");
|
||||
expect(sdk).toContain("getEvents");
|
||||
expect(types).toContain("ErrorEnvelope");
|
||||
expect(types).toContain("SseEvent");
|
||||
});
|
||||
|
||||
it("echoes a valid correlation UUID and replaces an invalid one", async () => {
|
||||
const app = await createApp();
|
||||
await app.ready();
|
||||
|
||||
const accepted = await app.inject({
|
||||
headers: { "x-correlation-id": "018f6d52-3348-7a3a-a741-7f65f31f9218" },
|
||||
method: "GET",
|
||||
url: "/api/v1/bootstrap",
|
||||
});
|
||||
const replaced = await app.inject({
|
||||
headers: { "x-correlation-id": "not-a-uuid" },
|
||||
method: "GET",
|
||||
url: "/api/v1/bootstrap",
|
||||
});
|
||||
|
||||
expect(accepted.headers["x-correlation-id"]).toBe("018f6d52-3348-7a3a-a741-7f65f31f9218");
|
||||
expect(replaced.headers["x-correlation-id"]).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(replaced.headers["x-correlation-id"]).not.toBe("not-a-uuid");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("accepts only the frozen error codes and details whitelist", () => {
|
||||
expect(Object.keys(stableEngineeringErrors)).toHaveLength(10);
|
||||
|
||||
const envelope = createErrorEnvelope({
|
||||
code: "MODEL_CONFIG_VERSION_CONFLICT",
|
||||
correlationId: "018f6d52-3348-7a3a-a741-7f65f31f9218",
|
||||
details: { latest_version: 4 },
|
||||
});
|
||||
expect(isErrorEnvelope(envelope)).toBe(true);
|
||||
expect(envelope.error.message_key).toBe("MODEL_CONFIG_VERSION_CONFLICT");
|
||||
|
||||
const leaked = {
|
||||
error: {
|
||||
...envelope.error,
|
||||
details: {
|
||||
latest_version: 4,
|
||||
supplier_error: "credential=not-real",
|
||||
stack: "internal stack",
|
||||
path: "private path",
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(isErrorEnvelope(leaked)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { EventHub } from "../../apps/api/src/event-hub.js";
|
||||
|
||||
const eventHub = new EventHub();
|
||||
let app: Awaited<ReturnType<typeof createApp>>;
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
app = await createApp({ eventHub });
|
||||
app.get("/__test/entity", async (request) => ({ entity_ref: (request.query as { ref?: string }).ref ?? null }));
|
||||
app.get("/__test/models", async () => ({ source: "rest" }));
|
||||
const apiUrl = await app.listen({ host: "127.0.0.1", port: 0 });
|
||||
|
||||
vite = await createServer({
|
||||
configFile: false,
|
||||
root: process.cwd(),
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
proxy: {
|
||||
"/__test": apiUrl,
|
||||
"/api": apiUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
eventHub.disconnectAll();
|
||||
await vite.close();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test("SSE remains a hint and recovers through REST", async ({ page }) => {
|
||||
const published = [
|
||||
{
|
||||
entity_ref: "projects:project-1",
|
||||
event_id: 40,
|
||||
event_type: "project_state_changed",
|
||||
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||
state_version: 2,
|
||||
},
|
||||
{
|
||||
config_set_version: 9,
|
||||
entity_ref: "models:current",
|
||||
event_id: 42,
|
||||
event_type: "model_config_changed",
|
||||
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||
},
|
||||
] as const;
|
||||
|
||||
await page.goto(`${webUrl}/tests/e2e/fixtures/event-sync.html`);
|
||||
await expect(page.locator("#status")).toHaveText("connected");
|
||||
expect(eventHub.subscriberCount).toBe(1);
|
||||
|
||||
eventHub.publish(published[0]);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_entity").length))
|
||||
.toBe(1);
|
||||
|
||||
eventHub.publish(published[1]);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length))
|
||||
.toBe(1);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "refetch_models").length))
|
||||
.toBe(1);
|
||||
|
||||
eventHub.disconnectAll();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => window.wp0EventFixture.timeline.filter((entry) => entry.action === "bootstrap").length))
|
||||
.toBe(2);
|
||||
|
||||
const timeline = await page.evaluate(() => window.wp0EventFixture.timeline);
|
||||
const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_EVT;
|
||||
if (evidenceDirectory) {
|
||||
mkdirSync(evidenceDirectory, { recursive: true });
|
||||
writeFileSync(
|
||||
resolve(evidenceDirectory, "sse-events.json"),
|
||||
`${JSON.stringify({ events: published, status: "passed" }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
resolve(evidenceDirectory, "network-timeline.json"),
|
||||
`${JSON.stringify({ status: "passed", timeline }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>WP0-02 event sync fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="status">starting</main>
|
||||
<script type="module" src="/tests/e2e/fixtures/event-sync.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { connectEventSource, createEventSyncController } from "../../../apps/web/src/event-sync.js";
|
||||
|
||||
interface TimelineEntry {
|
||||
action: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wp0EventFixture: {
|
||||
timeline: TimelineEntry[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const timeline: TimelineEntry[] = [];
|
||||
const request = async (url: string, action: string, detail?: string) => {
|
||||
timeline.push({ action, ...(detail ? { detail } : {}) });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${action} failed`);
|
||||
await response.json();
|
||||
};
|
||||
|
||||
const controller = createEventSyncController({
|
||||
bootstrap: () => request("/api/v1/bootstrap", "bootstrap"),
|
||||
refetchEntity: (entityRef) =>
|
||||
request(`/__test/entity?ref=${encodeURIComponent(entityRef)}`, "refetch_entity", entityRef),
|
||||
refetchModels: (input) =>
|
||||
request("/__test/models", "refetch_models", input.reason),
|
||||
});
|
||||
const source = connectEventSource("/api/v1/events", controller);
|
||||
source.addEventListener("open", () => {
|
||||
timeline.push({ action: "connected" });
|
||||
document.getElementById("status")!.textContent = "connected";
|
||||
});
|
||||
|
||||
window.wp0EventFixture = { timeline };
|
||||
@@ -20,7 +20,7 @@ describe("TASK-WP0-01 minimum toolchain", () => {
|
||||
});
|
||||
|
||||
it("loads and closes Fastify with the frozen Swagger plugin", async () => {
|
||||
const app = createApp();
|
||||
const app = await createApp();
|
||||
await app.ready();
|
||||
expect(app.hasPlugin("@fastify/swagger")).toBe(true);
|
||||
await app.close();
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createEventSyncController,
|
||||
type EventSyncDependencies,
|
||||
} from "../../apps/web/src/event-sync.js";
|
||||
import { isSseEvent } from "../../packages/shared-contracts/src/index.js";
|
||||
|
||||
function dependencies() {
|
||||
return {
|
||||
bootstrap: vi.fn(async () => undefined),
|
||||
refetchEntity: vi.fn(async () => undefined),
|
||||
refetchModels: vi.fn(async () => undefined),
|
||||
} satisfies EventSyncDependencies;
|
||||
}
|
||||
|
||||
describe("TDD-WP0-EVT-001 REST refetch", () => {
|
||||
it("accepts only fixed non-sensitive event fields and separated versions", () => {
|
||||
expect(
|
||||
isSseEvent({
|
||||
config_set_version: 7,
|
||||
entity_ref: "models:current",
|
||||
event_id: 1,
|
||||
event_type: "model_config_changed",
|
||||
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSseEvent({
|
||||
entity_ref: "models:runtime",
|
||||
event_id: 2,
|
||||
event_type: "model_runtime_changed",
|
||||
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||
runtime_availability_version: 12,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSseEvent({
|
||||
config_set_version: 7,
|
||||
entity_ref: "models:current",
|
||||
event_id: 3,
|
||||
event_type: "model_changed",
|
||||
occurred_at: "2026-07-27T09:00:02.000Z",
|
||||
prompt: "private body",
|
||||
runtime_availability_version: 12,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses REST for every event and bootstraps once after a sequence gap", async () => {
|
||||
const calls = dependencies();
|
||||
const controller = createEventSyncController(calls);
|
||||
|
||||
await controller.handleEvent({
|
||||
entity_ref: "projects:project-1",
|
||||
event_id: 10,
|
||||
event_type: "project_state_changed",
|
||||
occurred_at: "2026-07-27T09:00:00.000Z",
|
||||
state_version: 4,
|
||||
});
|
||||
await controller.handleEvent({
|
||||
config_set_version: 8,
|
||||
entity_ref: "models:current",
|
||||
event_id: 12,
|
||||
event_type: "model_config_changed",
|
||||
occurred_at: "2026-07-27T09:00:01.000Z",
|
||||
});
|
||||
|
||||
expect(calls.refetchEntity).toHaveBeenCalledWith("projects:project-1");
|
||||
expect(calls.bootstrap).toHaveBeenCalledTimes(1);
|
||||
expect(calls.refetchModels).toHaveBeenCalledWith({
|
||||
configSetVersion: 8,
|
||||
reason: "config",
|
||||
});
|
||||
});
|
||||
|
||||
it("bootstraps once per disconnect incident and never applies event payload as truth", async () => {
|
||||
const calls = dependencies();
|
||||
const controller = createEventSyncController(calls);
|
||||
|
||||
await controller.handleDisconnect();
|
||||
await controller.handleDisconnect();
|
||||
expect(calls.bootstrap).toHaveBeenCalledTimes(1);
|
||||
|
||||
await controller.handleEvent({
|
||||
entity_ref: "storage:local",
|
||||
event_id: 21,
|
||||
event_type: "storage_state_changed",
|
||||
occurred_at: "2026-07-27T09:00:02.000Z",
|
||||
state_version: 9,
|
||||
});
|
||||
await controller.handleDisconnect();
|
||||
|
||||
expect(calls.bootstrap).toHaveBeenCalledTimes(2);
|
||||
expect(calls.refetchEntity).toHaveBeenCalledWith("storage:local");
|
||||
expect(calls.refetchEntity).not.toHaveBeenCalledWith(expect.objectContaining({ state_version: 9 }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user