@cosmicdrift/kumiko-framework 0.215.6 → 0.215.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.215.
|
|
3
|
+
"version": "0.215.7",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
"./package.json": "./package.json"
|
|
195
195
|
},
|
|
196
196
|
"dependencies": {
|
|
197
|
-
"@cosmicdrift/kumiko-types": "0.215.
|
|
197
|
+
"@cosmicdrift/kumiko-types": "0.215.7",
|
|
198
198
|
"bullmq": "^5.76.7",
|
|
199
199
|
"bun-types": "^1.3.13",
|
|
200
200
|
"hono": "^4.13.1",
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
"zod": "^4.4.3"
|
|
211
211
|
},
|
|
212
212
|
"devDependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.215.
|
|
213
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.215.7",
|
|
214
214
|
"bun-types": "^1.3.13",
|
|
215
215
|
"pino-pretty": "^13.1.3"
|
|
216
216
|
},
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Pins createRequestHelper against a real setupTestStack (buildServer + JWT),
|
|
2
|
+
// not the mock-Hono unit suite. Covers success, structured errors, batch, and
|
|
3
|
+
// extra-header forwarding — the paths every integration test relies on via
|
|
4
|
+
// stack.http.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { defineFeature } from "../../engine";
|
|
9
|
+
import { NotFoundError, UnprocessableError, writeFailure } from "../../errors";
|
|
10
|
+
import { setupTestStack, type TestStack } from "../test-stack";
|
|
11
|
+
import { TestUsers } from "../test-users";
|
|
12
|
+
|
|
13
|
+
let stack: TestStack;
|
|
14
|
+
|
|
15
|
+
const pingFeature = defineFeature("reqhelp", (r) => {
|
|
16
|
+
r.writeHandler(
|
|
17
|
+
"echo",
|
|
18
|
+
z.object({ note: z.string().min(1) }),
|
|
19
|
+
async (event) => ({
|
|
20
|
+
isSuccess: true as const,
|
|
21
|
+
data: { note: event.payload.note, userId: event.user.id },
|
|
22
|
+
}),
|
|
23
|
+
{ access: { openToAll: true } },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
r.writeHandler(
|
|
27
|
+
"boom",
|
|
28
|
+
z.object({}),
|
|
29
|
+
async () =>
|
|
30
|
+
writeFailure(new UnprocessableError("reqhelp-boom", { i18nKey: "errors.unprocessable" })),
|
|
31
|
+
{ access: { openToAll: true } },
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
r.queryHandler(
|
|
35
|
+
"lookup",
|
|
36
|
+
z.object({ id: z.string().min(1) }),
|
|
37
|
+
async (query) => {
|
|
38
|
+
if (query.payload.id === "missing") {
|
|
39
|
+
throw new NotFoundError("thing", query.payload.id, { i18nKey: "errors.notFound" });
|
|
40
|
+
}
|
|
41
|
+
return { id: query.payload.id, ok: true };
|
|
42
|
+
},
|
|
43
|
+
{ access: { openToAll: true } },
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
stack = await setupTestStack({ features: [pingFeature] });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await stack.cleanup();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("createRequestHelper via setupTestStack.http", () => {
|
|
56
|
+
test("writeOk posts to /api/write and returns handler data", async () => {
|
|
57
|
+
const data = await stack.http.writeOk<{ note: string; userId: string }>(
|
|
58
|
+
"reqhelp:write:echo",
|
|
59
|
+
{ note: "hello" },
|
|
60
|
+
TestUsers.admin,
|
|
61
|
+
);
|
|
62
|
+
expect(data).toEqual({ note: "hello", userId: TestUsers.admin.id });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("queryOk posts to /api/query and returns handler data", async () => {
|
|
66
|
+
const data = await stack.http.queryOk<{ id: string; ok: boolean }>(
|
|
67
|
+
"reqhelp:query:lookup",
|
|
68
|
+
{ id: "abc" },
|
|
69
|
+
TestUsers.admin,
|
|
70
|
+
);
|
|
71
|
+
expect(data).toEqual({ id: "abc", ok: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("writeErr returns structured WriteErrorInfo with httpStatus", async () => {
|
|
75
|
+
const err = await stack.http.writeErr("reqhelp:write:boom", {}, TestUsers.admin);
|
|
76
|
+
expect(err.code).toBe("unprocessable");
|
|
77
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
78
|
+
expect(err.i18nKey).toBe("errors.unprocessable");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("queryErr returns structured WriteErrorInfo for not-found", async () => {
|
|
82
|
+
const err = await stack.http.queryErr(
|
|
83
|
+
"reqhelp:query:lookup",
|
|
84
|
+
{ id: "missing" },
|
|
85
|
+
TestUsers.admin,
|
|
86
|
+
);
|
|
87
|
+
expect(err.code).toBe("not_found");
|
|
88
|
+
expect(err.httpStatus).toBe(404);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("writeOk throws when the write fails (so suites cannot ignore failures)", async () => {
|
|
92
|
+
await expect(stack.http.writeOk("reqhelp:write:boom", {}, TestUsers.admin)).rejects.toThrow(
|
|
93
|
+
/reqhelp:write:boom/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("batch posts commands and returns per-command results", async () => {
|
|
98
|
+
const res = await stack.http.batch(
|
|
99
|
+
[
|
|
100
|
+
{ type: "reqhelp:write:echo", payload: { note: "a" } },
|
|
101
|
+
{ type: "reqhelp:write:echo", payload: { note: "b" } },
|
|
102
|
+
],
|
|
103
|
+
TestUsers.admin,
|
|
104
|
+
);
|
|
105
|
+
expect(res.ok).toBe(true);
|
|
106
|
+
const body = (await res.json()) as {
|
|
107
|
+
isSuccess?: boolean;
|
|
108
|
+
results?: readonly { isSuccess?: boolean; data?: { note?: string } }[];
|
|
109
|
+
};
|
|
110
|
+
expect(body.isSuccess).toBe(true);
|
|
111
|
+
expect(body.results?.map((r) => r.data?.note)).toEqual(["a", "b"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("writeWithHeaders forwards extra headers alongside auth", async () => {
|
|
115
|
+
const res = await stack.http.writeWithHeaders(
|
|
116
|
+
"reqhelp:write:echo",
|
|
117
|
+
{ note: "hdr" },
|
|
118
|
+
TestUsers.admin,
|
|
119
|
+
{ "X-Correlation-ID": "corr-42" },
|
|
120
|
+
);
|
|
121
|
+
expect(res.ok).toBe(true);
|
|
122
|
+
const body = (await res.json()) as { isSuccess?: boolean; data?: { note?: string } };
|
|
123
|
+
expect(body.isSuccess).toBe(true);
|
|
124
|
+
expect(body.data?.note).toBe("hdr");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type { JwtHelper } from "../../api/jwt";
|
|
4
|
+
import type { SessionUser } from "../../engine/types";
|
|
5
|
+
import { createRequestHelper } from "../request-helper";
|
|
6
|
+
|
|
7
|
+
const user: SessionUser = {
|
|
8
|
+
id: "u1",
|
|
9
|
+
tenantId: "t1",
|
|
10
|
+
roles: ["member"],
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function jwtStub() {
|
|
14
|
+
const signed: unknown[] = [];
|
|
15
|
+
const jwt = {
|
|
16
|
+
sign: async (payload: unknown) => {
|
|
17
|
+
signed.push(payload);
|
|
18
|
+
return `j.${Buffer.from(JSON.stringify(payload)).toString("base64url")}`;
|
|
19
|
+
},
|
|
20
|
+
} as unknown as JwtHelper;
|
|
21
|
+
return { jwt, signed };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function appRecording(
|
|
25
|
+
handler: (c: {
|
|
26
|
+
req: { json: () => Promise<unknown>; header: (n: string) => string | undefined };
|
|
27
|
+
}) => Response | Promise<Response>,
|
|
28
|
+
) {
|
|
29
|
+
const calls: { path: string; auth?: string; extraHeaders: Record<string, string | undefined> }[] =
|
|
30
|
+
[];
|
|
31
|
+
const app = new Hono();
|
|
32
|
+
for (const path of ["/api/write", "/api/query", "/api/command", "/api/batch"]) {
|
|
33
|
+
app.post(path, async (c) => {
|
|
34
|
+
calls.push({
|
|
35
|
+
path,
|
|
36
|
+
auth: c.req.header("authorization"),
|
|
37
|
+
extraHeaders: { "x-trace": c.req.header("x-trace") },
|
|
38
|
+
});
|
|
39
|
+
return handler({ req: { json: () => c.req.json(), header: (n) => c.req.header(n) } });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return { app, calls };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("createRequestHelper", () => {
|
|
46
|
+
test("writeOk posts JSON to /api/write and resolves the success data", async () => {
|
|
47
|
+
const { app, calls } = appRecording(() =>
|
|
48
|
+
Response.json({ isSuccess: true, data: { written: true } }),
|
|
49
|
+
);
|
|
50
|
+
const { jwt } = jwtStub();
|
|
51
|
+
const http = createRequestHelper(app, jwt);
|
|
52
|
+
|
|
53
|
+
const data = await http.writeOk("todo.create", { title: "x" }, user);
|
|
54
|
+
expect(data).toEqual({ written: true });
|
|
55
|
+
expect(calls).toHaveLength(1);
|
|
56
|
+
expect(calls[0]!.path).toBe("/api/write");
|
|
57
|
+
expect(calls[0]!.auth).toMatch(/^Bearer j\./);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("sessionCreator is consulted once per user and its sid reaches the jwt", async () => {
|
|
61
|
+
let creations = 0;
|
|
62
|
+
const { app } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
63
|
+
const { jwt, signed } = jwtStub();
|
|
64
|
+
const http = createRequestHelper(app, jwt, {
|
|
65
|
+
sessionCreator: async () => {
|
|
66
|
+
creations += 1;
|
|
67
|
+
return "sid-42";
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await http.writeOk("a.b", {}, user);
|
|
72
|
+
await http.queryOk("c.d", {}, user);
|
|
73
|
+
expect(creations).toBe(1);
|
|
74
|
+
expect(signed).toHaveLength(2);
|
|
75
|
+
expect(signed[0]).toMatchObject({ id: "u1", tenantId: "t1", sid: "sid-42" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("user-provided sid skips the sessionCreator entirely", async () => {
|
|
79
|
+
const { app } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
80
|
+
const { jwt, signed } = jwtStub();
|
|
81
|
+
let creations = 0;
|
|
82
|
+
const http = createRequestHelper(app, jwt, {
|
|
83
|
+
sessionCreator: async () => {
|
|
84
|
+
creations += 1;
|
|
85
|
+
throw new Error("must not be called");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
await http.writeOk("a.b", {}, { ...user, sid: "pre-set" });
|
|
89
|
+
expect(creations).toBe(0);
|
|
90
|
+
expect(signed[0]).toMatchObject({ sid: "pre-set" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("writeOk surfaces the formatted failure including cause details", async () => {
|
|
94
|
+
const { app } = appRecording(() =>
|
|
95
|
+
Response.json(
|
|
96
|
+
{
|
|
97
|
+
error: {
|
|
98
|
+
code: "internal_error",
|
|
99
|
+
details: { causeName: "DbError", causeMessage: "connection lost" },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ status: 500 },
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
const http = createRequestHelper(app, jwtStub().jwt);
|
|
106
|
+
expect(http.writeOk("todo.create", {}, user)).rejects.toThrow(
|
|
107
|
+
'Expected write "todo.create" to succeed but got error: internal_error (DbError: connection lost)',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("queryErr returns the wire error plus http status, but throws on success", async () => {
|
|
112
|
+
const failing = appRecording(() =>
|
|
113
|
+
Response.json({ error: { code: "validation_failed", message: "nope" } }, { status: 400 }),
|
|
114
|
+
);
|
|
115
|
+
const http = createRequestHelper(failing.app, jwtStub().jwt);
|
|
116
|
+
const err = await http.queryErr("q.type", {}, user);
|
|
117
|
+
expect(err.code).toBe("validation_failed");
|
|
118
|
+
expect(err.httpStatus).toBe(400);
|
|
119
|
+
|
|
120
|
+
const succeeding = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
121
|
+
const http2 = createRequestHelper(succeeding.app, jwtStub().jwt);
|
|
122
|
+
expect(http2.queryErr("q.type", {}, user)).rejects.toThrow(
|
|
123
|
+
'Expected query "q.type" to fail but it succeeded',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("batch posts the commands array unchanged to /api/batch", async () => {
|
|
128
|
+
const bodies: unknown[] = [];
|
|
129
|
+
const app = new Hono();
|
|
130
|
+
app.post("/api/batch", async (c) => {
|
|
131
|
+
bodies.push(await c.req.json());
|
|
132
|
+
return Response.json({ isSuccess: true, data: [] });
|
|
133
|
+
});
|
|
134
|
+
const http = createRequestHelper(app, jwtStub().jwt);
|
|
135
|
+
await http.batch(
|
|
136
|
+
[
|
|
137
|
+
{ type: "a", payload: { x: 1 } },
|
|
138
|
+
{ type: "b", payload: {} },
|
|
139
|
+
],
|
|
140
|
+
user,
|
|
141
|
+
"req-9",
|
|
142
|
+
);
|
|
143
|
+
expect(bodies[0]).toEqual({
|
|
144
|
+
commands: [
|
|
145
|
+
{ type: "a", payload: { x: 1 } },
|
|
146
|
+
{ type: "b", payload: {} },
|
|
147
|
+
],
|
|
148
|
+
requestId: "req-9",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("extra headers are forwarded alongside the auth header", async () => {
|
|
153
|
+
const { app, calls } = appRecording(() => Response.json({ isSuccess: true, data: {} }));
|
|
154
|
+
const http = createRequestHelper(app, jwtStub().jwt);
|
|
155
|
+
await http.writeWithHeaders("a.b", {}, user, { "x-trace": "tr-1" });
|
|
156
|
+
expect(calls[0]!.extraHeaders["x-trace"]).toBe("tr-1");
|
|
157
|
+
expect(calls[0]!.auth).toMatch(/^Bearer /);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildMultipartBody } from "../multipart-helper";
|
|
3
|
+
|
|
4
|
+
describe("buildMultipartBody", () => {
|
|
5
|
+
test("serializes text fields so a Response can parse them back", async () => {
|
|
6
|
+
const fd = new FormData();
|
|
7
|
+
fd.set("title", "hello world");
|
|
8
|
+
fd.set("count", "42");
|
|
9
|
+
|
|
10
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
11
|
+
expect(contentType).toMatch(/^multipart\/form-data; boundary=kumikoBnd/i);
|
|
12
|
+
|
|
13
|
+
const parsed = await new Response(body, {
|
|
14
|
+
headers: { "content-type": contentType },
|
|
15
|
+
}).formData();
|
|
16
|
+
expect(parsed.get("title")).toBe("hello world");
|
|
17
|
+
expect(parsed.get("count")).toBe("42");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("carries file name and bytes through", async () => {
|
|
21
|
+
const fd = new FormData();
|
|
22
|
+
fd.set("doc", new File(["PDF-bytes"], "report.pdf", { type: "application/pdf" }));
|
|
23
|
+
|
|
24
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
25
|
+
const text = typeof body === "string" ? body : await new Response(body).text();
|
|
26
|
+
|
|
27
|
+
expect(contentType).toMatch(/^multipart\/form-data; boundary=kumikoBnd/i);
|
|
28
|
+
expect(text).toContain('filename="report.pdf"');
|
|
29
|
+
expect(text).toContain("PDF-bytes");
|
|
30
|
+
expect(text.endsWith(`--${contentType.split("boundary=")[1]}--\r\n`)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("mixed fields and files round-trip together", async () => {
|
|
34
|
+
const fd = new FormData();
|
|
35
|
+
fd.set("note", "attached");
|
|
36
|
+
fd.set("file", new File(["abc"], "a.txt"));
|
|
37
|
+
|
|
38
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
39
|
+
const parsed = await new Response(body, {
|
|
40
|
+
headers: { "content-type": contentType },
|
|
41
|
+
}).formData();
|
|
42
|
+
expect(parsed.get("note")).toBe("attached");
|
|
43
|
+
// Structural check: happy-dom and bun can expose different File realms,
|
|
44
|
+
// so instanceof would fail depending on file order in the same process.
|
|
45
|
+
const file = parsed.get("file") as { name?: string; size?: number } | null;
|
|
46
|
+
expect(file?.name).toBe("a.txt");
|
|
47
|
+
expect(file?.size).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
});
|