@zerotal/arch 1.7.0

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 (160) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -0
  4. package/api-surface.md +283 -0
  5. package/docs/about.md +676 -0
  6. package/docs/admin/actions.md +183 -0
  7. package/docs/admin/auth.md +89 -0
  8. package/docs/admin/dashboard.md +155 -0
  9. package/docs/admin/extending-ui.md +229 -0
  10. package/docs/admin/extending.md +205 -0
  11. package/docs/admin/forms.md +155 -0
  12. package/docs/admin/index.md +144 -0
  13. package/docs/admin/operations.md +224 -0
  14. package/docs/admin/references.md +174 -0
  15. package/docs/admin/resources.md +132 -0
  16. package/docs/admin/structure.md +158 -0
  17. package/docs/admin/tables.md +185 -0
  18. package/docs/admin/testing.md +138 -0
  19. package/docs/ai.md +373 -0
  20. package/docs/application.md +435 -0
  21. package/docs/arch.md +263 -0
  22. package/docs/assets.md +755 -0
  23. package/docs/audit.md +367 -0
  24. package/docs/authentication.md +1000 -0
  25. package/docs/authorization.md +529 -0
  26. package/docs/broadcasting/channels.md +170 -0
  27. package/docs/broadcasting/client.md +108 -0
  28. package/docs/broadcasting/events.md +190 -0
  29. package/docs/broadcasting/index.md +163 -0
  30. package/docs/broadcasting/references.md +84 -0
  31. package/docs/broadcasting/testing.md +132 -0
  32. package/docs/cache.md +387 -0
  33. package/docs/carbon.md +830 -0
  34. package/docs/changelog.md +465 -0
  35. package/docs/client/auth.md +113 -0
  36. package/docs/client/errors.md +139 -0
  37. package/docs/client/files.md +118 -0
  38. package/docs/client/index.md +128 -0
  39. package/docs/client/references.md +58 -0
  40. package/docs/client/requests.md +131 -0
  41. package/docs/client/resilience.md +141 -0
  42. package/docs/client/testing.md +146 -0
  43. package/docs/commands.md +450 -0
  44. package/docs/components.md +2427 -0
  45. package/docs/config-system.md +258 -0
  46. package/docs/container.md +596 -0
  47. package/docs/context.md +579 -0
  48. package/docs/contributing.md +164 -0
  49. package/docs/controllers.md +354 -0
  50. package/docs/conventions.md +466 -0
  51. package/docs/cookies.md +101 -0
  52. package/docs/csrf.md +206 -0
  53. package/docs/database.md +476 -0
  54. package/docs/deployment.md +434 -0
  55. package/docs/devtools.md +1013 -0
  56. package/docs/email-verification.md +320 -0
  57. package/docs/encryption.md +380 -0
  58. package/docs/errors.md +317 -0
  59. package/docs/events.md +420 -0
  60. package/docs/flow/components.md +846 -0
  61. package/docs/flow/decorators.md +458 -0
  62. package/docs/flow/events.md +420 -0
  63. package/docs/flow/forms.md +551 -0
  64. package/docs/flow/index.md +568 -0
  65. package/docs/flow/layouts.md +793 -0
  66. package/docs/flow/lifecycle.md +336 -0
  67. package/docs/flow/pagination.md +402 -0
  68. package/docs/flow/performance.md +262 -0
  69. package/docs/flow/references.md +317 -0
  70. package/docs/flow/routing.md +550 -0
  71. package/docs/flow/testing.md +447 -0
  72. package/docs/getting-started.md +286 -0
  73. package/docs/health.md +269 -0
  74. package/docs/helpers.md +423 -0
  75. package/docs/i18n.md +414 -0
  76. package/docs/index.md +65 -0
  77. package/docs/inertia/build.md +95 -0
  78. package/docs/inertia/devtools.md +197 -0
  79. package/docs/inertia/index.md +267 -0
  80. package/docs/inertia/middleware.md +81 -0
  81. package/docs/inertia/props.md +507 -0
  82. package/docs/inertia/references.md +144 -0
  83. package/docs/inertia/rendering.md +189 -0
  84. package/docs/inertia/ssr.md +102 -0
  85. package/docs/inspirations.md +257 -0
  86. package/docs/lifecycle.md +331 -0
  87. package/docs/lock.md +454 -0
  88. package/docs/logger.md +517 -0
  89. package/docs/media.md +582 -0
  90. package/docs/middleware.md +509 -0
  91. package/docs/migrations.md +529 -0
  92. package/docs/monitor.md +377 -0
  93. package/docs/notifications.md +848 -0
  94. package/docs/orm/casts.md +365 -0
  95. package/docs/orm/factories.md +231 -0
  96. package/docs/orm/index.md +663 -0
  97. package/docs/orm/lifecycle.md +421 -0
  98. package/docs/orm/queries.md +692 -0
  99. package/docs/orm/relationships.md +440 -0
  100. package/docs/orm/serialization.md +251 -0
  101. package/docs/package-development.md +536 -0
  102. package/docs/pagination.md +340 -0
  103. package/docs/password-reset.md +335 -0
  104. package/docs/providers.md +447 -0
  105. package/docs/query-builder.md +465 -0
  106. package/docs/queue.md +549 -0
  107. package/docs/rate-limiting.md +253 -0
  108. package/docs/responses.md +336 -0
  109. package/docs/roles-and-2fa.md +418 -0
  110. package/docs/routing.md +1008 -0
  111. package/docs/scaffolding.md +335 -0
  112. package/docs/scheduler.md +533 -0
  113. package/docs/seeding.md +229 -0
  114. package/docs/session.md +451 -0
  115. package/docs/social.md +524 -0
  116. package/docs/storage.md +622 -0
  117. package/docs/structure.md +230 -0
  118. package/docs/support-policy.md +114 -0
  119. package/docs/telemetry.md +505 -0
  120. package/docs/tenancy.md +608 -0
  121. package/docs/testing/browser.md +153 -0
  122. package/docs/testing/console.md +130 -0
  123. package/docs/testing/database.md +238 -0
  124. package/docs/testing/flow-browser.md +216 -0
  125. package/docs/testing/http.md +441 -0
  126. package/docs/testing/index.md +240 -0
  127. package/docs/testing/mocking.md +410 -0
  128. package/docs/upgrade.md +192 -0
  129. package/docs/validator.md +549 -0
  130. package/docs/view.md +453 -0
  131. package/package.json +60 -0
  132. package/src/bin/mcp.ts +73 -0
  133. package/src/config.ts +63 -0
  134. package/src/errors.ts +27 -0
  135. package/src/index.ts +56 -0
  136. package/src/install/ArchInstallCommand.ts +189 -0
  137. package/src/install/detect.ts +98 -0
  138. package/src/install/guidelines.ts +290 -0
  139. package/src/install/markers.ts +77 -0
  140. package/src/install/mcpConfig.ts +81 -0
  141. package/src/mcp/index.ts +27 -0
  142. package/src/mcp/jsonrpc.ts +133 -0
  143. package/src/mcp/server.ts +307 -0
  144. package/src/mcp/stdio.ts +111 -0
  145. package/src/mcp/types.ts +146 -0
  146. package/src/probe/ArchProbeCommand.ts +48 -0
  147. package/src/probe/sentinel.ts +13 -0
  148. package/src/probe/topics.ts +291 -0
  149. package/src/provider/ArchProvider.ts +101 -0
  150. package/src/tools/_probe.ts +164 -0
  151. package/src/tools/apiSurface.ts +241 -0
  152. package/src/tools/appInfo.ts +88 -0
  153. package/src/tools/baselines.ts +210 -0
  154. package/src/tools/context.ts +37 -0
  155. package/src/tools/doctor.ts +93 -0
  156. package/src/tools/index.ts +50 -0
  157. package/src/tools/logs.ts +265 -0
  158. package/src/tools/routes.ts +133 -0
  159. package/src/tools/schema.ts +119 -0
  160. package/src/tools/searchDocs.ts +345 -0
@@ -0,0 +1,216 @@
1
+ ---
2
+ title: FlowBrowser
3
+ description: Drive a real page against a real server inside bun test, and assert on the WebSocket bridge itself.
4
+ ---
5
+
6
+ # FlowBrowser
7
+
8
+ `FlowBrowser` runs a headless browser against your app inside `bun test`, so a test
9
+ can assert on the thing `FlowTest` cannot reach: the WebSocket bridge.
10
+
11
+ ```ts
12
+ import { FlowBrowser } from "@zerotal/testing/browser";
13
+
14
+ const browser = await FlowBrowser.serve(() => import("../bootstrap/app.ts").then((m) => m.default));
15
+ const page = await browser.visit("/settings");
16
+
17
+ await page.waitForConnection();
18
+ await page.click('[flow\\:click="save"]');
19
+ await page.waitForPatch();
20
+
21
+ expect(await page.text("#status")).toBe("Saved");
22
+ ```
23
+
24
+ ## Why this exists
25
+
26
+ [`FlowTest`](/docs/flow/testing) mounts a component and drives its server-side
27
+ lifecycle. It never opens a socket, so it renders the full markup every time and
28
+ every assertion passes — which is exactly the problem. The failures Flow ships have
29
+ one shape: **the HTML is fine and the transport is dead.** SSR renders, snapshot
30
+ assertions pass, the suite is green, and the app does nothing in a browser.
31
+
32
+ Those failures are invisible to every server-side test by construction. `FlowBrowser`
33
+ is the harness that can see them.
34
+
35
+ Reach for `FlowTest` first: it is faster, needs no browser, and covers component
36
+ logic. Reach for `FlowBrowser` when the thing under test only happens after
37
+ hydration.
38
+
39
+ ## Requirements
40
+
41
+ A Chrome or Chromium install. Nothing else — no extra dependency is added to
42
+ `@zerotal/testing`, because [`Bun.WebView`](https://bun.sh/docs) drives the browser
43
+ and reads the transport through the DevTools Protocol.
44
+
45
+ The harness reaches a browser two ways:
46
+
47
+ | Mode | When |
48
+ | ----------- | -------------------------------------------- |
49
+ | **spawn** | Default. Launches its own headless instance. |
50
+ | **connect** | When `ZT_BROWSER_CDP_URL` is set. |
51
+
52
+ > **Windows** — Bun cannot currently spawn Chrome on Windows: it reports
53
+ > `Failed to spawn Chrome` even when the binary is present, because the spawn path
54
+ > uses `--remote-debugging-pipe`. Use connect-mode there. Start a browser once:
55
+ >
56
+ > ```bash
57
+ > chrome --headless=new --remote-debugging-port=9222 --user-data-dir=/tmp/zt-chrome
58
+ > ```
59
+ >
60
+ > then read `webSocketDebuggerUrl` from `http://127.0.0.1:9222/json/version` and set
61
+ > `ZT_BROWSER_CDP_URL` to it.
62
+
63
+ ## Skipping, and why CI must not
64
+
65
+ `FlowBrowser.availability()` reports whether a browser can be reached, and it is
66
+ resolved by actually opening one — a binary that exists but cannot start is the
67
+ failure worth catching, and `which chrome` cannot see it.
68
+
69
+ The two environments differ deliberately. **A browser suite that skips itself in CI
70
+ is worse than no suite**, because it reports green for exactly the failures it exists
71
+ to catch. So pair the skip with a guard that runs regardless:
72
+
73
+ ```ts
74
+ import { FlowBrowser, browserAvailability, browserRequired } from "@zerotal/testing/browser";
75
+
76
+ const availability = await browserAvailability();
77
+
78
+ test("a browser is reachable, so this suite is not silently skipped", () => {
79
+ if (!browserRequired()) return; // a developer's machine may legitimately lack one
80
+ expect(availability.available).toBe(true);
81
+ });
82
+
83
+ describe.skipIf(!availability.available)("checkout", () => {
84
+ // …
85
+ });
86
+ ```
87
+
88
+ `browserRequired()` is true when `CI` is set. `browserAvailability()` resolves a
89
+ `BrowserAvailability` — `{ available, mode, reason }`, where `mode` is `"spawn"`,
90
+ `"connect"` or `"none"` and `reason` is the sentence to print when skipping.
91
+
92
+ `CDP_URL_ENV` is exported as the name of the connect-mode variable, so a test helper
93
+ that sets it does not have to hard-code the string.
94
+
95
+ ## Serving the app
96
+
97
+ ```ts
98
+ const browser = await FlowBrowser.serve(bootstrap, options?);
99
+ ```
100
+
101
+ `bootstrap` is the same callback [`createTestApp`](/docs/testing/http) takes, so the
102
+ app under test is configured the way the rest of your suite configures it rather than
103
+ through a second bootstrap that can drift. The server binds an OS-assigned port.
104
+
105
+ `options` is a `FlowBrowserOptions`:
106
+
107
+ | Option | Meaning |
108
+ | --------- | ------------------------------------------------------------- |
109
+ | `timeout` | Milliseconds every `waitFor*` allows. Default `5000`. |
110
+ | `setup` | Register routes before the server starts — for fixture pages. |
111
+
112
+ Call `await browser.stop()` in `afterAll`: it closes every page it opened, then stops
113
+ the server.
114
+
115
+ ```ts
116
+ let browser: FlowBrowser;
117
+ beforeAll(async () => {
118
+ browser = await FlowBrowser.serve(bootstrap);
119
+ });
120
+ afterAll(async () => {
121
+ await browser.stop();
122
+ });
123
+ ```
124
+
125
+ ## Driving a page
126
+
127
+ `browser.visit(path)` returns a `BrowserPage`. Open one per test.
128
+
129
+ **Reading**
130
+
131
+ | Method | Returns |
132
+ | --------------------------- | ---------------------------------------- |
133
+ | `text(selector)` | Trimmed `textContent`, or `null` |
134
+ | `html(selector)` | `innerHTML`, or `null` |
135
+ | `count(selector)` | How many elements match |
136
+ | `attribute(selector, name)` | One attribute, or `null` |
137
+ | `connection()` | `"online"`, `"offline"`, or `null` |
138
+ | `url()` | Path and query, for asserting a redirect |
139
+ | `evaluate<T>(expression)` | Anything, as a real JS value |
140
+
141
+ `evaluate` takes an **expression**, not statements — wrap a block in an IIFE. Only one
142
+ evaluation runs at a time per page, so never wrap these in `Promise.all`.
143
+
144
+ **Acting**
145
+
146
+ `click(selector)`, `type(selector, text)` and `press(key)` fire real trusted events.
147
+ `type` clicks the field first, because the browser types into whatever holds focus.
148
+
149
+ **Waiting**
150
+
151
+ Never sleep. Every wait is on an observable signal, and every one throws on timeout
152
+ with the connection state in the message.
153
+
154
+ | Method | Waits for |
155
+ | --------------------------- | ------------------------------------------- |
156
+ | `waitForPatch()` | A WebSocket frame caused by the last action |
157
+ | `waitForConnection()` | The bridge to report `online` |
158
+ | `waitForCount(selector, n)` | At least `n` matches |
159
+ | `waitFor(expression)` | Any in-page expression to be truthy |
160
+
161
+ `waitForPatch()` is the primitive. The received-frame count is captured when the
162
+ action is dispatched and this waits for it to rise, so an assertion cannot race the
163
+ transport. A harness that raced it would produce flaky tests, and a flaky browser
164
+ suite gets deleted.
165
+
166
+ ## Asserting on the transport
167
+
168
+ `page.transport()` reports what Chrome saw on the wire — which the page cannot lie
169
+ about. A client that degraded silently still shows zero upgraded sockets here.
170
+
171
+ ```ts
172
+ expect(page.socketUpgraded()).toBe(true);
173
+ expect(page.transport().statuses).toContain(101);
174
+ expect(await page.connection()).toBe("online");
175
+ ```
176
+
177
+ It returns a `TransportReport`:
178
+
179
+ | Field | Meaning |
180
+ | ---------- | -------------------------------------------- |
181
+ | `created` | Sockets the page opened |
182
+ | `upgraded` | Handshakes that answered `101` |
183
+ | `statuses` | Every handshake status that arrived |
184
+ | `errored` | Chrome reported a frame-level failure |
185
+ | `frames` | An `ObservedFrame[]` — direction and payload |
186
+
187
+ > **Assert that a `101` was seen — never that the status was not something else.**
188
+ > A refused upgrade does not arrive as a handshake response at all: Chrome reports a
189
+ > frame error instead. A test asserting `statuses` does not contain `403` passes
190
+ > vacuously, because that event never fires.
191
+
192
+ `frames` carries the payloads, which is how you assert on what actually crossed the
193
+ wire rather than on what the DOM ended up showing:
194
+
195
+ ```ts
196
+ const sent = page.transport().frames.filter((f) => f.direction === "sent");
197
+ expect(sent.some((f) => f.payload.includes(`"method":"save"`))).toBe(true);
198
+ ```
199
+
200
+ ## What this cannot cover
201
+
202
+ **The origin guard.** The harness talks to `http://127.0.0.1:<port>`, which is the
203
+ app's own origin, so `allowedOrigins` is satisfied trivially and no test here can
204
+ catch a misconfigured one. That failure only appears behind a reverse proxy, where
205
+ the browser sends the public origin and the app compares it against the loopback
206
+ address it bound to.
207
+
208
+ That case has its own tool — `bun zt doctor --url=https://your-site` sends a real
209
+ handshake through the real proxy. See [Deployment](/docs/deployment). Do not read a
210
+ green browser suite as coverage of it.
211
+
212
+ ## Next steps
213
+
214
+ - [Flow testing](/docs/flow/testing) — `FlowTest`, for everything that does not need a browser.
215
+ - [HTTP Tests](/docs/testing/http) — `createTestApp`, the bootstrap this shares.
216
+ - [Browser Tests](/docs/testing/browser) — Playwright, for cross-browser end-to-end work beyond Flow.
@@ -0,0 +1,441 @@
1
+ ---
2
+ title: HTTP Tests
3
+ description: Boot your real application and exercise it over real HTTP requests with chainable response assertions.
4
+ ---
5
+
6
+ # HTTP Tests
7
+
8
+ HTTP tests boot your real application on a random port and exercise it over real
9
+ requests. `createTestApp()` returns a `TestApp` client; every request returns a
10
+ chainable `TestResponse` for assertions.
11
+
12
+ ## Getting Started
13
+
14
+ The testing helpers ship in `@zerotal/testing`. There is no provider to register
15
+ and no config file — you import the helpers directly inside your test files.
16
+
17
+ ```bash
18
+ # in your project root
19
+ bun add -d @zerotal/testing
20
+ ```
21
+
22
+ ```typescript
23
+ // src/tests/PostTest.ts
24
+ import { createTestApp, type TestApp } from "@zerotal/testing";
25
+ ```
26
+
27
+ ## Booting the app
28
+
29
+ Boot the app once per suite and close it when the suite finishes:
30
+
31
+ ```typescript
32
+ // src/tests/PostTest.ts
33
+ import { describe, it, beforeAll, afterAll } from "bun:test";
34
+ import { createTestApp, type TestApp } from "@zerotal/testing";
35
+ import { app } from "../bootstrap/app.ts";
36
+
37
+ let testApp: TestApp;
38
+
39
+ beforeAll(async () => {
40
+ testApp = await createTestApp(() => app);
41
+ });
42
+
43
+ afterAll(() => testApp.close());
44
+ ```
45
+
46
+ `createTestApp()` boots the app and starts it on an OS-assigned port (port `0`):
47
+
48
+ ```typescript
49
+ function createTestApp(
50
+ bootstrap: () => Application | Promise<Application>,
51
+ setup?: () => void,
52
+ ): Promise<TestApp>;
53
+ ```
54
+
55
+ The optional `setup` callback runs after `resetTestState()` but before `app.start()` —
56
+ use it to register routes or override bindings specific to the suite.
57
+
58
+ > **Note** — Routes registered in `setup` are compiled into the server before it
59
+ > starts, so they resolve correctly during the suite.
60
+
61
+ ### One app per process
62
+
63
+ Bun runs a whole suite in a single process, and the database connection is
64
+ process-global. So when several test files each call `createTestApp()` with the same
65
+ bootstrap module, they share one booted app: the first call boots it, the rest get the
66
+ same instance back.
67
+
68
+ That means `close()` in a file's `afterAll` resets per-test state (auth, flash, captured
69
+ mail) but leaves the app running for the files still to come. Without this, the first
70
+ file's teardown closed the connection every later file depended on, and the file that
71
+ failed was a correct one that merely ran second:
72
+
73
+ ```text
74
+ [Zerotal ORM] No database connection. Is DatabaseProvider registered?
75
+ ```
76
+
77
+ Keep calling `close()` in every file — it is what resets state between them. Passing a
78
+ `setup` callback opts out of sharing (routes cannot be registered twice against a
79
+ running server), and that app is torn down fully by its own `close()`. To tear the
80
+ shared app down explicitly — a global teardown, or a test asserting no timers leak —
81
+ call `closeSharedTestApps()`.
82
+
83
+ ## Sending requests
84
+
85
+ ```typescript
86
+ // in a test
87
+ await testApp.get("/path");
88
+ await testApp.post("/path", body); // body serialized as JSON
89
+ await testApp.put("/path", body);
90
+ await testApp.patch("/path", body);
91
+ await testApp.delete("/path");
92
+ await testApp.head("/path");
93
+ await testApp.options("/path");
94
+ await testApp.request("/path", fetchInit); // raw Fetch RequestInit
95
+ ```
96
+
97
+ Each method returns a `TestResponse`. The `post`, `put`, and `patch` helpers set
98
+ `Content-Type: application/json` and JSON-encode the body for you.
99
+
100
+ ### Form submits
101
+
102
+ A route meant for a browser should be tested the way a browser reaches it. A JSON
103
+ `post()` does not travel the same path: form submits are what trigger the
104
+ redirect-back-with-errors branch of validation, the CSRF check, and any middleware
105
+ that reads `application/x-www-form-urlencoded`.
106
+
107
+ ```typescript
108
+ // in a test
109
+ const res = await testApp.postForm("/posts", { title: "Hello", published: true });
110
+
111
+ res.assertRedirect("/posts");
112
+ ```
113
+
114
+ `putForm` and `patchForm` send the same body with a different method. Values are
115
+ stringified; `null` and `undefined` fields are omitted rather than sent as the
116
+ strings `"null"` and `"undefined"`.
117
+
118
+ ### File uploads
119
+
120
+ `multipart()` is the only way to exercise a route that reads an uploaded file.
121
+ Build the attachments with `fakeFile`:
122
+
123
+ ```typescript
124
+ // in a test
125
+ import { fakeFile } from "@zerotal/testing";
126
+
127
+ const res = await testApp.multipart("/avatar", {
128
+ name: "Alice",
129
+ avatar: fakeFile.image("avatar.png", { width: 64, height: 64 }),
130
+ });
131
+
132
+ res.assertCreated();
133
+ ```
134
+
135
+ The files `fakeFile` builds are real: a PNG has the PNG signature, a valid header
136
+ at the size you asked for, and compressed pixel data. That matters because the
137
+ framework never trusts what an upload claims to be — `UploadedFile.store()` and
138
+ `detectType()` sniff the leading bytes and name the stored file from what they
139
+ find. A placeholder full of zero bytes declared as `image/png` would pass a
140
+ `mimes` check and then be stored as `application/octet-stream`, so the test would
141
+ pass while the behavior it describes never happened.
142
+
143
+ | Builder | Produces |
144
+ | ------------------------------ | ---------------------------------------------------- |
145
+ | `fakeFile.image(name, opts?)` | A valid PNG at the given `width`/`height`. |
146
+ | `fakeFile.jpeg(name?)` | A JPEG with a real JFIF header. |
147
+ | `fakeFile.gif(name?)` | A GIF89a image. |
148
+ | `fakeFile.pdf(name?)` | A one-page PDF. |
149
+ | `fakeFile.create(name, c, t?)` | Exactly the contents you give it. |
150
+ | `fakeFile.sized(name, bytes)` | Filler of an exact size, for exercising size limits. |
151
+
152
+ For a unit test that hands an `UploadedFile` straight to the code under test
153
+ without a request, build one with `UploadedFile.fake('a.png', { type, size })`.
154
+
155
+ ### Authentication
156
+
157
+ ```typescript
158
+ // in a test
159
+
160
+ // Forge a signed session cookie so the request is treated as authenticated
161
+ await testApp.actingAs(user).get("/dashboard");
162
+
163
+ // Clear auth between tests
164
+ testApp.actingAsGuest();
165
+
166
+ // Pre-seed arbitrary session data
167
+ await testApp.withSession({ locale: "fr", flash: "saved" }).get("/profile");
168
+ ```
169
+
170
+ `actingAs(user)` forges a signed session cookie containing `user_id`, reading
171
+ `session.secret` and `session.cookie` from your config. `withSession()` preserves
172
+ any `user_id` already set by `actingAs()`.
173
+
174
+ ### Headers and redirects
175
+
176
+ ```typescript
177
+ // in a test
178
+ testApp.withHeaders({ "X-App-Version": "2" });
179
+ testApp.withCookie("theme", "dark");
180
+ testApp.asJson(); // Accept: application/json
181
+
182
+ // Follow Location redirects automatically (up to 10 hops)
183
+ const res = await testApp.followingRedirects().post("/login", { email, password });
184
+ res.assertOk(); // landed on the dashboard
185
+
186
+ testApp.withoutFollowingRedirects(); // restore default
187
+ ```
188
+
189
+ These chain off `testApp` and apply to the request that follows.
190
+
191
+ `asJson()` is worth reaching for whenever a test asserts on a JSON body. The
192
+ framework negotiates error responses off the `Accept` header, so without it a
193
+ failed request comes back as the rendered HTML error page and the JSON assertions
194
+ have nothing to parse.
195
+
196
+ > **Warning** — Redirect following is **off** by default, so a `POST` that
197
+ > succeeds returns the `3xx` response, not the final page. Call
198
+ > `followingRedirects()` when you want to assert against the destination.
199
+
200
+ ## When a test fails on a 500
201
+
202
+ An exception inside a route is converted to a response before the test sees it,
203
+ so the assertion reports `500` and the body is an error page. `withoutExceptionHandling()`
204
+ hands you the original instead:
205
+
206
+ ```typescript
207
+ // in a test
208
+ const res = await testApp.withoutExceptionHandling().get("/checkout");
209
+
210
+ expect(res.exception()).toBeInstanceOf(PaymentDeclinedError);
211
+ ```
212
+
213
+ Errors are captured either way — `res.exception()` works without opting in, and a
214
+ failing assertion quotes the stack that caused the failure rather than making you
215
+ re-run the route by hand. What `withoutExceptionHandling()` adds is silence: it
216
+ skips the handler's reporting, so an expected failure stops writing a stack trace
217
+ into the test output. Call `withExceptionHandling()` to restore the default.
218
+
219
+ ## TestResponse assertions
220
+
221
+ > **Note** — Every assertion is synchronous and chainable, including the body
222
+ > ones (`assertSee`, `assertJson`, `assertJsonPath`). The response body is read
223
+ > once when the `TestResponse` is built, so nothing here needs `await` — and a
224
+ > forgotten one can no longer turn a failure into an unhandled rejection.
225
+
226
+ Every request returns a `TestResponse`. Assertions are chainable and throw
227
+ descriptive errors on failure.
228
+
229
+ ```typescript
230
+ // in a test
231
+
232
+ // Status
233
+ res.assertStatus(200);
234
+ res.assertOk(); // 200
235
+ res.assertCreated(); // 201
236
+ res.assertNoContent(); // 204
237
+ res.assertUnauthorized(); // 401
238
+ res.assertForbidden(); // 403
239
+ res.assertNotFound(); // 404
240
+ res.assertUnprocessable(); // 422
241
+ res.assertMovedPermanently(); // 301
242
+
243
+ // Redirects
244
+ res.assertRedirect("/dashboard");
245
+
246
+ // Headers
247
+ res.assertHeader("Content-Type");
248
+ res.assertHeader("Content-Type", "application/json");
249
+ res.assertHeaderMissing("X-Debug");
250
+
251
+ // JSON body
252
+ res.assertJson({ title: "Hello" });
253
+ res.assertJsonPath("user.name", "Alice");
254
+ res.assertJsonPath("data.0.id", 1);
255
+ res.assertJsonCount(3); // top-level array
256
+ res.assertJsonCount(3, "data"); // array at body.data
257
+
258
+ // HTML body
259
+ res.assertSee("Welcome, Alice");
260
+ res.assertDontSee("Error");
261
+ res.assertBodyContains("<h1>");
262
+ res.assertSeeText("Welcome, Alice"); // ignores the markup between the words
263
+ res.assertDontSeeText("Error");
264
+
265
+ // Validation
266
+ res.assertInvalid(); // failed on something
267
+ res.assertInvalid("email"); // failed on email
268
+ res.assertInvalid(["email", "password"]);
269
+ res.assertInvalid({ email: "required" }); // and the message contains "required"
270
+ res.assertValid(); // nothing failed
271
+ res.assertValid("email");
272
+
273
+ // Auth
274
+ res.assertAuthenticated();
275
+ res.assertAuthenticatedAs(user); // or a bare id
276
+ res.assertGuest();
277
+
278
+ // Cookies
279
+ res.assertCookie("zerotal_session");
280
+ res.assertCookie("theme", "dark");
281
+ res.assertCookieMissing("remember_me");
282
+
283
+ // Session
284
+ res.assertSessionHas("user_id");
285
+ res.assertSessionHas("status", "saved");
286
+ res.assertSessionMissing("errors");
287
+ res.assertSessionHasErrors(["email"]);
288
+ res.assertSessionHasNoErrors();
289
+
290
+ // Inertia
291
+ res.assertInertia("Posts/Index");
292
+ res.assertInertia("Posts/Show", { post: { id: 1 } }); // props match partially
293
+ res.assertInertiaProp("filters");
294
+ ```
295
+
296
+ ### Validation, whichever shape it arrives in
297
+
298
+ A failed validation reaches the client two different ways: an API client gets
299
+ `422` with an `errors` object, and a form submit gets a redirect with the errors
300
+ flashed to the session. `assertInvalid` reads both, so the assertion is the same
301
+ either way:
302
+
303
+ ```typescript
304
+ // in a test
305
+
306
+ // API client
307
+ const api = await testApp.asJson().post("/posts", {});
308
+ api.assertUnprocessable().assertInvalid(["title", "body"]);
309
+
310
+ // Form submit
311
+ const form = await testApp.postForm("/posts", {});
312
+ form.assertRedirect("/posts/create").assertInvalid(["title", "body"]);
313
+ ```
314
+
315
+ `res.validationErrors()` returns the same errors as a `{ field: string[] }` record
316
+ when you want to assert on them directly.
317
+
318
+ ### Sessions
319
+
320
+ Session data is decoded through the application's own `session.driver`, so
321
+ whatever format the driver writes is the format the assertions read. That is why
322
+ these need a response produced by `createTestApp()` with a session driver bound —
323
+ a `TestResponse` built by hand has nothing to decode with, and says so rather than
324
+ reporting the key as absent.
325
+
326
+ `res.session()` returns the decoded record when you want to inspect it directly.
327
+
328
+ > **Note** — `assertSessionMissing` throws when the session cannot be decoded
329
+ > rather than passing. "I could not read the session" is not evidence that the key
330
+ > is absent, and an assertion that treats it as such can never fail.
331
+
332
+ Reading the body directly:
333
+
334
+ ```typescript
335
+ // in a test
336
+ const data = res.json<{ id: number }>();
337
+ const html = res.text();
338
+ const code = res.status; // number
339
+ const ok = res.ok; // boolean
340
+ ```
341
+
342
+ ## Unit-testing a controller
343
+
344
+ Skip HTTP entirely for fast, focused controller tests by faking the context:
345
+
346
+ ```typescript
347
+ // src/tests/PostControllerTest.ts
348
+ import { HttpContext } from "zerotal";
349
+
350
+ const ctx = HttpContext.fake("http://localhost/posts/42", { method: "GET" });
351
+ ctx.params = { id: "42" };
352
+
353
+ await new PostController().show({ http: ctx });
354
+
355
+ expect(ctx.response?.status).toBe(200);
356
+ ```
357
+
358
+ > **Tip** — Faking the context skips booting the server, so these controller tests
359
+ > run far faster than a full HTTP round-trip.
360
+
361
+ ## Which should I use?
362
+
363
+ - **`createTestApp()` (full HTTP)** — when you need middleware, routing, sessions,
364
+ or auth to run end to end. This is the realistic path and what most feature tests
365
+ want.
366
+ - **`HttpContext.fake()` (unit)** — when you want to exercise one controller method
367
+ in isolation without paying the cost of booting a server.
368
+
369
+ ## References
370
+
371
+ ### TestApp
372
+
373
+ | Member | Signature | Description |
374
+ | ------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------ |
375
+ | `actingAs` | `actingAs(user: { id: number \| string }): this` | Forge a session cookie for `user.id`. Chainable. |
376
+ | `actingAsGuest` | `actingAsGuest(): this` | Clear the auth cookie. Chainable. |
377
+ | `withSession` | `withSession(data: Record<string, unknown>): this` | Pre-seed session data, merged with any `actingAs` data. Chainable. |
378
+ | `withHeaders` | `withHeaders(headers: Record<string, string>): this` | Merge headers into every request. Chainable. |
379
+ | `asJson` | `asJson(): this` | Send `Accept: application/json`. Chainable. |
380
+ | `withCookie` / `withCookies` | `(name, value): this` / `(record): this` | Attach cookies to every request. Chainable. |
381
+ | `withoutCookies` | `withoutCookies(): this` | Drop the attached cookies. Chainable. |
382
+ | `followingRedirects` | `followingRedirects(): this` | Follow up to 10 `Location` redirects. Chainable. |
383
+ | `withoutFollowingRedirects` | `withoutFollowingRedirects(): this` | Restore the default (no following). Chainable. |
384
+ | `withoutExceptionHandling` | `withoutExceptionHandling(): this` | Capture the raw exception and return a bare `500`. Chainable. |
385
+ | `withExceptionHandling` | `withExceptionHandling(): this` | Restore normal error rendering. Chainable. |
386
+ | `get` / `head` / `options` | `(url, headers?): Promise<TestResponse>` | Send a request with no body. |
387
+ | `post` / `put` / `patch` | `(url, body, headers?): Promise<TestResponse>` | Send a request with a JSON body. |
388
+ | `postForm` / `putForm` / `patchForm` | `(url, body?, headers?): Promise<TestResponse>` | Send a URL-encoded form body. |
389
+ | `multipart` | `(url, body?, headers?, method?): Promise<TestResponse>` | Send `multipart/form-data`, with files. |
390
+ | `delete` | `delete(url, headers?): Promise<TestResponse>` | Send a `DELETE` request. |
391
+ | `request` | `request(url, init?): Promise<TestResponse>` | Raw Fetch-style request. |
392
+ | `close` | `close(): Promise<void>` | Stop the server and reset framework state. |
393
+ | `port` | `get port(): number` | The OS-assigned port. |
394
+ | `baseUrl` | `get baseUrl(): string` | `http://localhost:{port}`. |
395
+ | `app` | `get app(): Application` | The underlying application, for resolving bindings. |
396
+
397
+ ### TestResponse
398
+
399
+ | Member | Signature | Description |
400
+ | ----------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------- |
401
+ | `assertStatus` | `assertStatus(expected: number): this` | Assert the status code. |
402
+ | `assertOk` / `assertCreated` / `assertNoContent` | `(): this` | Assert `200` / `201` / `204`. |
403
+ | `assertSuccessful` | `(): this` | Assert any `2xx`. |
404
+ | `assertMovedPermanently` | `(): this` | Assert `301`. |
405
+ | `assertUnauthorized` / `assertForbidden` / `assertNotFound` / `assertUnprocessable` | `(): this` | Assert `401` / `403` / `404` / `422`. |
406
+ | `assertServerError` | `(): this` | Assert `500`. |
407
+ | `assertRedirect` | `assertRedirect(url: string): this` | Assert a `3xx` whose `Location` contains `url`. |
408
+ | `assertHeader` | `assertHeader(name, value?): this` | Assert a header is present (and contains `value`). |
409
+ | `assertHeaderMissing` | `assertHeaderMissing(name): this` | Assert a header is absent. |
410
+ | `assertJson` | `assertJson(expected): this` | Assert each key in `expected` matches the JSON body. |
411
+ | `assertJsonPath` | `assertJsonPath(path, expected): this` | Assert a dot-notation path in the JSON body. |
412
+ | `assertJsonCount` | `assertJsonCount(count, key?): this` | Assert an array length at the body or `key`. |
413
+ | `assertSee` / `assertBodyContains` | `(needle): this` | Assert the body contains `needle`. |
414
+ | `assertDontSee` | `assertDontSee(needle): this` | Assert the body does not contain `needle`. |
415
+ | `assertSeeText` / `assertDontSeeText` | `(needle): this` | The same, against the body with its tags stripped. |
416
+ | `assertInvalid` | `assertInvalid(fields?): this` | Assert validation failed, optionally on `fields`. |
417
+ | `assertValid` | `assertValid(fields?): this` | Assert validation did not fail. |
418
+ | `validationErrors` | `(): Record<string, string[]> \| null` | The errors, from the body or the session. |
419
+ | `assertAuthenticated` | `(): this` | Assert the session holds a `user_id`. |
420
+ | `assertAuthenticatedAs` | `assertAuthenticatedAs(user \| id): this` | Assert that specific user is signed in. |
421
+ | `assertGuest` | `(): this` | Assert nobody is signed in. |
422
+ | `assertCookie` | `assertCookie(name, value?): this` | Assert a `Set-Cookie` (and optional value). |
423
+ | `assertCookieMissing` | `assertCookieMissing(name): this` | Assert no such cookie is set. |
424
+ | `assertSessionHas` | `assertSessionHas(key, value?): this` | Assert the session contains `key`. |
425
+ | `assertSessionMissing` | `assertSessionMissing(key): this` | Assert the session lacks `key`. |
426
+ | `assertSessionHasErrors` / `assertSessionHasNoErrors` | `(fields?): this` | Assert flashed validation errors. |
427
+ | `session` | `(): Record<string, unknown> \| null` | The decoded session. |
428
+ | `assertInertia` | `assertInertia(component?, props?): this` | Assert the Inertia page and a partial prop match. |
429
+ | `assertInertiaProp` | `assertInertiaProp(key, value?): this` | Assert a single Inertia prop. |
430
+ | `inertia` | `(): InertiaPage \| null` | The Inertia page object, from either wire shape. |
431
+ | `exception` | `(): unknown` | The exception the request raised, if any. |
432
+ | `json` | `json<T>(): T` | Parse and return the full JSON body. |
433
+ | `text` | `text(): string` | Return the body as text. |
434
+ | `status` / `ok` / `headers` | getters | The underlying `Response` status, `ok`, and headers. |
435
+
436
+ ## Next steps
437
+
438
+ - [Database](/docs/testing/database) — isolating each test's data.
439
+ - [Mocking](/docs/testing/mocking) — asserting mail/queue/notification side effects.
440
+ - [Console Tests](/docs/testing/console) — run CLI commands in-process and assert output.
441
+ - [Factories](/docs/orm/factories) — building the records a request acts on.