@mandujs/core 0.21.0 → 0.22.1

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.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -71,6 +71,36 @@ export interface ResourceOptions {
71
71
  defaultLimit?: number;
72
72
  maxLimit?: number;
73
73
  };
74
+ /**
75
+ * Opt-in DB persistence config (Phase 4c).
76
+ *
77
+ * When set, the resource generator emits an additional `<name>.repo.ts`
78
+ * artifact with typed CRUD backed by `@mandujs/core/db`, and the schema
79
+ * pipeline (`mandu db plan` / `computeSchemaGeneration`) includes this
80
+ * resource in migrations.
81
+ *
82
+ * The concrete shape lives in
83
+ * `@mandujs/core/resource/ddl/persistence-types` — imported lazily to
84
+ * keep `ResourceOptions` usable on runtimes without the DDL subsystem.
85
+ * Non-persistent resources simply omit this field (existing behavior).
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * defineResource({
90
+ * name: "post",
91
+ * fields: { ... },
92
+ * options: {
93
+ * persistence: {
94
+ * provider: "sqlite",
95
+ * primaryKey: "id",
96
+ * indexes: [{ name: "posts_user_idx", fields: ["userId"], unique: false }],
97
+ * fieldOverrides: { id: { primary: true }, userId: { indexed: true } },
98
+ * },
99
+ * },
100
+ * });
101
+ * ```
102
+ */
103
+ persistence?: import("./ddl/persistence-types").ExtendedResourcePersistence;
74
104
  }
75
105
 
76
106
  // ============================================
@@ -27,6 +27,7 @@ import {
27
27
  sortRoutesByPriority,
28
28
  getPatternShape,
29
29
  } from "./fs-patterns";
30
+ import { mark, measure } from "../perf";
30
31
 
31
32
  // ═══════════════════════════════════════════════════════════════════════════
32
33
  // Scanner Class
@@ -55,6 +56,7 @@ export class FSScanner {
55
56
  * @returns 스캔 결과
56
57
  */
57
58
  async scan(rootDir: string): Promise<ScanResult> {
59
+ mark("router:scan");
58
60
  const startTime = Date.now();
59
61
  const routesDir = join(rootDir, this.config.routesDir);
60
62
 
@@ -87,6 +89,7 @@ export class FSScanner {
87
89
  // 통계 계산
88
90
  const stats = this.calculateStats(files, routes, Date.now() - startTime);
89
91
 
92
+ measure("router:scan", "router:scan");
90
93
  return {
91
94
  files,
92
95
  routes: sortRoutesByPriority(routes),
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Phase 6.3: error-boundary stack redaction tests.
3
+ *
4
+ * Verifies the contract enforced by `redactErrorForBoundary()` in
5
+ * runtime/server.ts — user-land `error.tsx` receives full fidelity in
6
+ * dev and a trimmed view in prod, with a stable `digest` correlating
7
+ * the two. This is the defensive layer that keeps server-side stack
8
+ * frames from reaching the browser.
9
+ */
10
+
11
+ import { describe, it, expect } from "bun:test";
12
+ import {
13
+ redactErrorForBoundary,
14
+ computeErrorDigest,
15
+ } from "../server";
16
+
17
+ function buildFakeError(): Error {
18
+ // Build an error with a long, predictable stack so we can assert on frame
19
+ // count. The Node stack format starts with `Error: msg\n at foo (...)`.
20
+ const e = new Error("boom");
21
+ e.stack = [
22
+ "Error: boom",
23
+ " at level0 (/proj/src/a.ts:10:5)",
24
+ " at level1 (/proj/src/a.ts:20:5)",
25
+ " at level2 (/proj/src/b.ts:30:5)",
26
+ " at level3 (/proj/src/b.ts:40:5)",
27
+ " at level4 (/proj/src/c.ts:50:5)",
28
+ " at level5 (/proj/src/c.ts:60:5)",
29
+ ].join("\n");
30
+ return e;
31
+ }
32
+
33
+ describe("redactErrorForBoundary() — dev mode (isDev=true)", () => {
34
+ it("returns the original Error unchanged", () => {
35
+ const original = buildFakeError();
36
+ const { error } = redactErrorForBoundary(original, true);
37
+ expect(error).toBe(original);
38
+ // Full stack preserved.
39
+ expect(error.stack).toContain("level5");
40
+ });
41
+
42
+ it("still produces a digest in dev", () => {
43
+ const original = buildFakeError();
44
+ const { digest } = redactErrorForBoundary(original, true);
45
+ expect(typeof digest).toBe("string");
46
+ expect(digest.length).toBe(8);
47
+ });
48
+ });
49
+
50
+ describe("redactErrorForBoundary() — prod mode (isDev=false)", () => {
51
+ it("returns a clone with preserved message", () => {
52
+ const original = buildFakeError();
53
+ const { error } = redactErrorForBoundary(original, false);
54
+ expect(error).not.toBe(original);
55
+ expect(error.message).toBe("boom");
56
+ expect(error.name).toBe("Error");
57
+ });
58
+
59
+ it("trims stack to header + top 3 frames", () => {
60
+ const original = buildFakeError();
61
+ const { error } = redactErrorForBoundary(original, false);
62
+ expect(typeof error.stack).toBe("string");
63
+ const lines = error.stack!.split("\n");
64
+ // Header + 3 frames = 4 lines.
65
+ expect(lines).toHaveLength(4);
66
+ expect(lines[0]).toBe("Error: boom");
67
+ expect(lines[1]).toContain("level0");
68
+ expect(lines[2]).toContain("level1");
69
+ expect(lines[3]).toContain("level2");
70
+ // Deeper frames are gone.
71
+ expect(error.stack).not.toContain("level3");
72
+ expect(error.stack).not.toContain("level4");
73
+ expect(error.stack).not.toContain("level5");
74
+ });
75
+
76
+ it("preserves .name on custom Error subclasses", () => {
77
+ class MyError extends Error {}
78
+ const custom = new MyError("hmm");
79
+ custom.name = "MyError";
80
+ const { error } = redactErrorForBoundary(custom, false);
81
+ expect(error.name).toBe("MyError");
82
+ expect(error.message).toBe("hmm");
83
+ });
84
+
85
+ it("handles errors with no stack gracefully", () => {
86
+ const noStack = new Error("bare");
87
+ noStack.stack = undefined;
88
+ const { error, digest } = redactErrorForBoundary(noStack, false);
89
+ expect(error.message).toBe("bare");
90
+ expect(error.stack).toBeUndefined();
91
+ expect(digest.length).toBe(8);
92
+ });
93
+ });
94
+
95
+ describe("computeErrorDigest()", () => {
96
+ it("produces the same digest for structurally identical errors", () => {
97
+ const a = new Error("same");
98
+ a.stack = "Error: same\n at foo (/x:1:1)";
99
+ const b = new Error("same");
100
+ b.stack = "Error: same\n at foo (/x:1:1)";
101
+ expect(computeErrorDigest(a)).toBe(computeErrorDigest(b));
102
+ });
103
+
104
+ it("produces a different digest when the message changes", () => {
105
+ const a = new Error("a");
106
+ a.stack = "Error: a\n at foo (/x:1:1)";
107
+ const b = new Error("b");
108
+ b.stack = "Error: b\n at foo (/x:1:1)";
109
+ expect(computeErrorDigest(a)).not.toBe(computeErrorDigest(b));
110
+ });
111
+
112
+ it("produces a different digest when the top frame changes", () => {
113
+ const a = new Error("same");
114
+ a.stack = "Error: same\n at foo (/x:1:1)";
115
+ const b = new Error("same");
116
+ b.stack = "Error: same\n at bar (/x:1:1)";
117
+ expect(computeErrorDigest(a)).not.toBe(computeErrorDigest(b));
118
+ });
119
+
120
+ it("returns an 8-char hex string", () => {
121
+ const d = computeErrorDigest(new Error("any"));
122
+ expect(d).toMatch(/^[0-9a-f]{8}$/);
123
+ });
124
+
125
+ it("handles Error with empty stack", () => {
126
+ const e = new Error("");
127
+ e.stack = "";
128
+ expect(computeErrorDigest(e)).toMatch(/^[0-9a-f]{8}$/);
129
+ });
130
+ });
131
+
132
+ describe("redactErrorForBoundary() ↔ computeErrorDigest() contract", () => {
133
+ it("dev digest equals prod digest for the same original error", () => {
134
+ // The digest must survive the redaction — same underlying event on
135
+ // client (redacted) and server logs (full) must be joinable.
136
+ const original = buildFakeError();
137
+ const dev = redactErrorForBoundary(original, true);
138
+ const prod = redactErrorForBoundary(original, false);
139
+ expect(dev.digest).toBe(prod.digest);
140
+ });
141
+ });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Phase 7.2 R1 Agent B — HDR client-side runtime tests.
3
+ *
4
+ * Coverage (pure unit — no DOM, no real WebSocket):
5
+ * 1. dispatchSlotRefetch returns false when no transport is
6
+ * installed (the default "no-router" fallback).
7
+ * 2. setHDRTransport + dispatchSlotRefetch returns the transport's
8
+ * `ok` flag so the caller can decide fallback vs. done.
9
+ * 3. A throwing transport never propagates; dispatchSlotRefetch
10
+ * swallows + returns false so the HMR client stays alive.
11
+ * 4. Transport is isolated per test via _resetRegistryForTests().
12
+ * 5. HDRPayload shape round-trips through the transport (routeId,
13
+ * slotPath, rebuildId, timestamp all preserved — no field is
14
+ * silently dropped).
15
+ *
16
+ * References:
17
+ * docs/bun/phase-7-2-team-plan.md §3 Agent B
18
+ * packages/core/src/runtime/hmr-client.ts — setHDRTransport,
19
+ * dispatchSlotRefetch, _resetRegistryForTests
20
+ */
21
+
22
+ import { beforeEach, describe, expect, test } from "bun:test";
23
+ import {
24
+ setHDRTransport,
25
+ dispatchSlotRefetch,
26
+ _resetRegistryForTests,
27
+ } from "../hmr-client";
28
+ import type { HDRPayload } from "../../bundler/hmr-types";
29
+
30
+ describe("Phase 7.2 Agent B — dispatchSlotRefetch", () => {
31
+ beforeEach(() => {
32
+ _resetRegistryForTests();
33
+ });
34
+
35
+ test("[1] returns false when no transport is installed (default fallback)", async () => {
36
+ const payload: HDRPayload = {
37
+ type: "slot-refetch",
38
+ routeId: "home",
39
+ slotPath: "app/page.slot.ts",
40
+ rebuildId: 1,
41
+ timestamp: Date.now(),
42
+ };
43
+ const ok = await dispatchSlotRefetch(payload);
44
+ expect(ok).toBe(false);
45
+ });
46
+
47
+ test("[2] returns true when the installed transport resolves ok:true", async () => {
48
+ const received: HDRPayload[] = [];
49
+ setHDRTransport(async (p) => {
50
+ received.push(p);
51
+ return { ok: true };
52
+ });
53
+ const payload: HDRPayload = {
54
+ type: "slot-refetch",
55
+ routeId: "dashboard",
56
+ slotPath: "app/dashboard/page.slot.ts",
57
+ rebuildId: 7,
58
+ timestamp: 1234567890,
59
+ };
60
+ const ok = await dispatchSlotRefetch(payload);
61
+ expect(ok).toBe(true);
62
+ // Full payload passed through unchanged.
63
+ expect(received.length).toBe(1);
64
+ expect(received[0]).toEqual(payload);
65
+ });
66
+
67
+ test("[3] transport that throws does NOT propagate — dispatchSlotRefetch returns false", async () => {
68
+ setHDRTransport(async () => {
69
+ throw new Error("transport kaboom");
70
+ });
71
+ const payload: HDRPayload = {
72
+ type: "slot-refetch",
73
+ routeId: "home",
74
+ slotPath: "app/page.slot.ts",
75
+ rebuildId: 1,
76
+ timestamp: Date.now(),
77
+ };
78
+ // Must NOT reject. A thrown error in the transport is logged and
79
+ // the caller sees `false` so it can fall back to a full reload.
80
+ const ok = await dispatchSlotRefetch(payload);
81
+ expect(ok).toBe(false);
82
+ });
83
+
84
+ test("[4] transport resolves ok:false with reason — dispatch returns false", async () => {
85
+ setHDRTransport(async () => ({ ok: false, reason: "no-route" as const }));
86
+ const payload: HDRPayload = {
87
+ type: "slot-refetch",
88
+ routeId: "home",
89
+ slotPath: "app/page.slot.ts",
90
+ rebuildId: 1,
91
+ timestamp: Date.now(),
92
+ };
93
+ const ok = await dispatchSlotRefetch(payload);
94
+ expect(ok).toBe(false);
95
+ });
96
+
97
+ test("[5] _resetRegistryForTests restores default (no-transport) behavior", async () => {
98
+ setHDRTransport(async () => ({ ok: true }));
99
+ _resetRegistryForTests();
100
+ const payload: HDRPayload = {
101
+ type: "slot-refetch",
102
+ routeId: "home",
103
+ slotPath: "app/page.slot.ts",
104
+ rebuildId: 1,
105
+ timestamp: Date.now(),
106
+ };
107
+ const ok = await dispatchSlotRefetch(payload);
108
+ // After reset the default "no-router" transport is back.
109
+ expect(ok).toBe(false);
110
+ });
111
+
112
+ test("[6] two sequential dispatches with different payloads deliver each to transport", async () => {
113
+ const received: HDRPayload[] = [];
114
+ setHDRTransport(async (p) => {
115
+ received.push(p);
116
+ return { ok: true };
117
+ });
118
+ const a: HDRPayload = {
119
+ type: "slot-refetch",
120
+ routeId: "home",
121
+ slotPath: "app/page.slot.ts",
122
+ rebuildId: 1,
123
+ timestamp: 100,
124
+ };
125
+ const b: HDRPayload = {
126
+ type: "slot-refetch",
127
+ routeId: "dashboard",
128
+ slotPath: "app/dashboard/page.slot.ts",
129
+ rebuildId: 2,
130
+ timestamp: 200,
131
+ };
132
+ await dispatchSlotRefetch(a);
133
+ await dispatchSlotRefetch(b);
134
+ expect(received.length).toBe(2);
135
+ expect(received[0]!.routeId).toBe("home");
136
+ expect(received[1]!.routeId).toBe("dashboard");
137
+ expect(received[1]!.rebuildId).toBe(2);
138
+ });
139
+
140
+ test("[7] transport may observe the full HDRPayload — no fields lost", async () => {
141
+ const captured: HDRPayload[] = [];
142
+ setHDRTransport(async (p) => {
143
+ captured.push(p);
144
+ return { ok: true };
145
+ });
146
+ const payload: HDRPayload = {
147
+ type: "slot-refetch",
148
+ routeId: "nested-route-abc123",
149
+ slotPath: "app/deeply/nested/page.slot.ts",
150
+ rebuildId: 9999,
151
+ timestamp: 1_700_000_000_000,
152
+ };
153
+ await dispatchSlotRefetch(payload);
154
+ expect(captured.length).toBe(1);
155
+ const first = captured[0]!;
156
+ expect(first.type).toBe("slot-refetch");
157
+ expect(first.routeId).toBe(payload.routeId);
158
+ expect(first.slotPath).toBe(payload.slotPath);
159
+ expect(first.rebuildId).toBe(payload.rebuildId);
160
+ expect(first.timestamp).toBe(payload.timestamp);
161
+ });
162
+
163
+ test("[8] latest setHDRTransport wins (idempotent setter semantics)", async () => {
164
+ let firstCalled = 0;
165
+ let secondCalled = 0;
166
+ setHDRTransport(async () => {
167
+ firstCalled++;
168
+ return { ok: false, reason: "disabled" as const };
169
+ });
170
+ setHDRTransport(async () => {
171
+ secondCalled++;
172
+ return { ok: true };
173
+ });
174
+ const payload: HDRPayload = {
175
+ type: "slot-refetch",
176
+ routeId: "home",
177
+ slotPath: "app/page.slot.ts",
178
+ rebuildId: 1,
179
+ timestamp: Date.now(),
180
+ };
181
+ const ok = await dispatchSlotRefetch(payload);
182
+ expect(firstCalled).toBe(0);
183
+ expect(secondCalled).toBe(1);
184
+ expect(ok).toBe(true);
185
+ });
186
+
187
+ test("[9] transport that resolves with 'fetch-failed' reason — dispatch returns false", async () => {
188
+ setHDRTransport(async () => ({
189
+ ok: false,
190
+ reason: "fetch-failed" as const,
191
+ }));
192
+ const payload: HDRPayload = {
193
+ type: "slot-refetch",
194
+ routeId: "home",
195
+ slotPath: "app/page.slot.ts",
196
+ rebuildId: 1,
197
+ timestamp: Date.now(),
198
+ };
199
+ const ok = await dispatchSlotRefetch(payload);
200
+ expect(ok).toBe(false);
201
+ });
202
+
203
+ test("[10] HDRPayload slotPath with Windows backslashes is preserved verbatim", async () => {
204
+ // The helper does no normalization on the browser side — the
205
+ // server ships whatever string it put in the payload. Lock that
206
+ // contract so future changes don't introduce silent mutation.
207
+ const captured: HDRPayload[] = [];
208
+ setHDRTransport(async (p) => {
209
+ captured.push(p);
210
+ return { ok: true };
211
+ });
212
+ const payload: HDRPayload = {
213
+ type: "slot-refetch",
214
+ routeId: "home",
215
+ slotPath: "C:\\proj\\app\\page.slot.ts",
216
+ rebuildId: 1,
217
+ timestamp: Date.now(),
218
+ };
219
+ await dispatchSlotRefetch(payload);
220
+ expect(captured.length).toBe(1);
221
+ expect(captured[0]!.slotPath).toBe("C:\\proj\\app\\page.slot.ts");
222
+ });
223
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Unit tests for the module-level HTTP error helpers (Phase 6.3).
3
+ *
4
+ * Companion to the ctx.unauthorized() / ctx.forbidden() / ctx.error()
5
+ * methods on ManduContext — those live in filling/context.ts. These
6
+ * standalone helpers must match ctx behavior for body shape while also
7
+ * covering the header-merging contract (WWW-Authenticate, custom headers).
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import { unauthorized, forbidden, badRequest } from "../http-errors";
12
+
13
+ async function readJson(res: Response): Promise<unknown> {
14
+ const clone = res.clone();
15
+ const text = await clone.text();
16
+ return text.length > 0 ? JSON.parse(text) : null;
17
+ }
18
+
19
+ describe("unauthorized()", () => {
20
+ it("returns 401 with the default WWW-Authenticate: Bearer header", async () => {
21
+ const res = unauthorized();
22
+ expect(res.status).toBe(401);
23
+ expect(res.headers.get("WWW-Authenticate")).toBe("Bearer");
24
+ expect(await readJson(res)).toEqual({ error: "Unauthorized" });
25
+ });
26
+
27
+ it("includes the caller-provided message in the JSON body", async () => {
28
+ const res = unauthorized("login required");
29
+ expect(res.status).toBe(401);
30
+ expect(await readJson(res)).toEqual({ error: "login required" });
31
+ });
32
+
33
+ it("merges custom headers alongside WWW-Authenticate", async () => {
34
+ const res = unauthorized(undefined, { headers: { "x-custom": "1" } });
35
+ expect(res.status).toBe(401);
36
+ expect(res.headers.get("x-custom")).toBe("1");
37
+ expect(res.headers.get("WWW-Authenticate")).toBe("Bearer");
38
+ expect(res.headers.get("Content-Type")).toContain("application/json");
39
+ });
40
+
41
+ it("lets callers override WWW-Authenticate (e.g. Basic realm)", () => {
42
+ const res = unauthorized("Token expired", {
43
+ headers: { "WWW-Authenticate": 'Basic realm="app"' },
44
+ });
45
+ expect(res.headers.get("WWW-Authenticate")).toBe('Basic realm="app"');
46
+ });
47
+ });
48
+
49
+ describe("forbidden()", () => {
50
+ it("returns 403 with the default JSON body", async () => {
51
+ const res = forbidden();
52
+ expect(res.status).toBe(403);
53
+ expect(await readJson(res)).toEqual({ error: "Forbidden" });
54
+ expect(res.headers.get("Content-Type")).toContain("application/json");
55
+ });
56
+
57
+ it("carries the provided message", async () => {
58
+ const res = forbidden("role mismatch");
59
+ expect(res.status).toBe(403);
60
+ expect(await readJson(res)).toEqual({ error: "role mismatch" });
61
+ });
62
+
63
+ it("merges custom response headers", () => {
64
+ const res = forbidden("no", { headers: { "x-trace": "abc" } });
65
+ expect(res.headers.get("x-trace")).toBe("abc");
66
+ expect(res.status).toBe(403);
67
+ });
68
+ });
69
+
70
+ describe("badRequest()", () => {
71
+ it("string input produces { error: <string> }", async () => {
72
+ const res = badRequest("string-only");
73
+ expect(res.status).toBe(400);
74
+ expect(await readJson(res)).toEqual({ error: "string-only" });
75
+ });
76
+
77
+ it("object input with message + errors produces { error, errors }", async () => {
78
+ const res = badRequest({
79
+ message: "invalid",
80
+ errors: { email: ["required"] },
81
+ });
82
+ expect(res.status).toBe(400);
83
+ expect(await readJson(res)).toEqual({
84
+ error: "invalid",
85
+ errors: { email: ["required"] },
86
+ });
87
+ });
88
+
89
+ it("object input without errors omits the key (no `errors: undefined` leak)", async () => {
90
+ const res = badRequest({ message: "simple" });
91
+ const body = (await readJson(res)) as Record<string, unknown>;
92
+ expect(body).toEqual({ error: "simple" });
93
+ // Explicit: `errors` key must not appear at all — users pattern-match on presence.
94
+ expect(Object.prototype.hasOwnProperty.call(body, "errors")).toBe(false);
95
+ });
96
+
97
+ it("no-args call defaults to 'Bad Request'", async () => {
98
+ const res = badRequest();
99
+ expect(res.status).toBe(400);
100
+ expect(await readJson(res)).toEqual({ error: "Bad Request" });
101
+ });
102
+
103
+ it("merges custom headers on both string and object inputs", () => {
104
+ const resA = badRequest("x", { headers: { "x-a": "1" } });
105
+ expect(resA.headers.get("x-a")).toBe("1");
106
+
107
+ const resB = badRequest({ message: "y" }, { headers: { "x-b": "2" } });
108
+ expect(resB.headers.get("x-b")).toBe("2");
109
+ });
110
+
111
+ it("response content-type is application/json; charset=utf-8", () => {
112
+ const res = badRequest("z");
113
+ const ct = res.headers.get("Content-Type") ?? "";
114
+ expect(ct).toContain("application/json");
115
+ expect(ct).toContain("charset=utf-8");
116
+ });
117
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Unit tests for `notFound()` / `isNotFoundResponse()` (Phase 6.3).
3
+ *
4
+ * Mirrors the shape of the redirect() tests (see packages/core/tests/server/
5
+ * redirect-loader.test.ts). These stay local to the helper — integration
6
+ * coverage that exercises the full SSR pipeline lives in the server test
7
+ * at packages/core/tests/server/not-found-page.test.ts.
8
+ */
9
+
10
+ import { describe, it, expect } from "bun:test";
11
+ import {
12
+ notFound,
13
+ isNotFoundResponse,
14
+ NOT_FOUND_BRAND,
15
+ } from "../not-found";
16
+ import { redirect } from "../redirect";
17
+ import { ManduFilling } from "../../filling/filling";
18
+ import { ManduContext } from "../../filling/context";
19
+
20
+ describe("notFound()", () => {
21
+ it("returns a Response with status 404", () => {
22
+ const res = notFound();
23
+ expect(res).toBeInstanceOf(Response);
24
+ expect(res.status).toBe(404);
25
+ });
26
+
27
+ it("carries the provided message in its body", async () => {
28
+ const res = notFound({ message: "Post not found" });
29
+ const body = await res.text();
30
+ expect(body).toBe("Post not found");
31
+ });
32
+
33
+ it("is recognized by isNotFoundResponse()", () => {
34
+ expect(isNotFoundResponse(notFound())).toBe(true);
35
+ expect(isNotFoundResponse(notFound({ message: "x" }))).toBe(true);
36
+ });
37
+
38
+ it("rejects a bare `new Response(null, { status: 404 })` (brand check)", () => {
39
+ const bare = new Response(null, { status: 404 });
40
+ expect(isNotFoundResponse(bare)).toBe(false);
41
+ // Double-check: something we know is 404 but not ours (e.g. upstream proxy)
42
+ const proxied = new Response("upstream 404", {
43
+ status: 404,
44
+ headers: { "Content-Type": "text/plain" },
45
+ });
46
+ expect(isNotFoundResponse(proxied)).toBe(false);
47
+ });
48
+
49
+ it("rejects a redirect() Response (notFound is a distinct brand)", () => {
50
+ expect(isNotFoundResponse(redirect("/foo"))).toBe(false);
51
+ });
52
+
53
+ it("rejects null / undefined / {} / primitives (type guard is strict)", () => {
54
+ expect(isNotFoundResponse(null)).toBe(false);
55
+ expect(isNotFoundResponse(undefined)).toBe(false);
56
+ expect(isNotFoundResponse({})).toBe(false);
57
+ expect(isNotFoundResponse("string")).toBe(false);
58
+ expect(isNotFoundResponse(404)).toBe(false);
59
+ expect(isNotFoundResponse(new Error("not a response"))).toBe(false);
60
+ });
61
+
62
+ it("status is always 404 regardless of the message", () => {
63
+ expect(notFound().status).toBe(404);
64
+ expect(notFound({ message: "a" }).status).toBe(404);
65
+ expect(notFound({ message: "" }).status).toBe(404); // empty still 404
66
+ expect(notFound({ message: "x".repeat(1000) }).status).toBe(404);
67
+ });
68
+
69
+ it("body is text/plain (not JSON) — explicit content-type contract", async () => {
70
+ const res = notFound({ message: "missing" });
71
+ const ct = res.headers.get("content-type") ?? "";
72
+ expect(ct).toContain("text/plain");
73
+ expect(ct).toContain("charset=utf-8");
74
+ // Body must be the literal message, not a JSON envelope.
75
+ expect(await res.text()).toBe("missing");
76
+ });
77
+
78
+ it("default body (no args) is the literal 'Not Found'", async () => {
79
+ const res = notFound();
80
+ expect(await res.text()).toBe("Not Found");
81
+ });
82
+
83
+ it("loader returning notFound() is picked up by isNotFoundResponse", async () => {
84
+ const filling = new ManduFilling();
85
+ filling.loader(() => notFound({ message: "post gone" }));
86
+
87
+ const ctx = new ManduContext(new Request("http://test/post/1"), { id: "1" });
88
+ const returned = await filling.executeLoader(ctx);
89
+ expect(isNotFoundResponse(returned)).toBe(true);
90
+ // And the body survived.
91
+ const body = await (returned as Response).text();
92
+ expect(body).toBe("post gone");
93
+ });
94
+
95
+ it("loader throwing notFound() is picked up the same way (Remix idiom)", async () => {
96
+ const filling = new ManduFilling();
97
+ filling.loader(() => {
98
+ throw notFound({ message: "thrown" });
99
+ });
100
+
101
+ const ctx = new ManduContext(new Request("http://test/x"), {});
102
+ let caught: unknown = null;
103
+ try {
104
+ await filling.executeLoader(ctx);
105
+ } catch (e) {
106
+ caught = e;
107
+ }
108
+ expect(isNotFoundResponse(caught)).toBe(true);
109
+ expect(await (caught as Response).text()).toBe("thrown");
110
+ });
111
+
112
+ it("integration simulation: loadPageData-like branch distinguishes return vs throw", async () => {
113
+ // Emulates the exact shape of the SSR pipeline's try/catch: `returned`
114
+ // and `thrown` both route through `isNotFoundResponse`. This is the
115
+ // contract the server integration relies on.
116
+ const returnCase = notFound({ message: "A" });
117
+ const throwCase = notFound({ message: "B" });
118
+
119
+ // Both must be recognised.
120
+ expect(isNotFoundResponse(returnCase)).toBe(true);
121
+ expect(isNotFoundResponse(throwCase)).toBe(true);
122
+
123
+ // And each preserves its own message (no shared mutable state).
124
+ expect(await returnCase.text()).toBe("A");
125
+ expect(await throwCase.text()).toBe("B");
126
+ });
127
+
128
+ it("accepts a call with no arguments and still produces a valid sentinel", () => {
129
+ const res = notFound();
130
+ expect(res).toBeInstanceOf(Response);
131
+ expect(res.status).toBe(404);
132
+ expect(isNotFoundResponse(res)).toBe(true);
133
+ });
134
+
135
+ it("exports NOT_FOUND_BRAND as a unique symbol keyed to the package", () => {
136
+ // The brand must be a Symbol.for() so multiple copies of @mandujs/core
137
+ // (e.g. monorepo duplication) agree on identity. If this changes we've
138
+ // broken cross-package detection — regression guard.
139
+ expect(typeof NOT_FOUND_BRAND).toBe("symbol");
140
+ expect(Symbol.keyFor(NOT_FOUND_BRAND)).toBe("@mandujs/core/not-found");
141
+ });
142
+
143
+ it("two separate notFound() calls produce independently-branded Responses", () => {
144
+ // WeakSet membership is per-instance — the brand on one must not
145
+ // leak to another. Important for test isolation.
146
+ const a = notFound();
147
+ const b = notFound();
148
+ expect(a).not.toBe(b);
149
+ expect(isNotFoundResponse(a)).toBe(true);
150
+ expect(isNotFoundResponse(b)).toBe(true);
151
+ });
152
+ });