@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,240 @@
1
+ ---
2
+ title: Testing
3
+ description: Boot your real app in tests and assert on HTTP, the database, console commands, browsers, and faked services.
4
+ ---
5
+
6
+ # Testing
7
+
8
+ Zerotal ships a complete testing toolkit built on Bun's test runner. It covers HTTP
9
+ integration testing against a real running server, transactional database isolation,
10
+ console-command testing, end-to-end browser tests, in-memory service fakes, plus
11
+ [factories](/docs/orm/factories) and a data generator for arranging state.
12
+
13
+ ```bash
14
+ # in your project root
15
+ bun test # run all test files
16
+ bun test --watch # re-run on change
17
+ bun test src/tests/PostTest.ts # a single file
18
+ ```
19
+
20
+ Everything is importable from `@zerotal/testing`, which is installed with the
21
+ default skeleton. To add it to an existing project:
22
+
23
+ ```bash
24
+ # in your project root
25
+ bun add -d @zerotal/testing
26
+ ```
27
+
28
+ ## Setting up your app for testing
29
+
30
+ Do this once per project. Every test file — yours and the ones in the package
31
+ guides — assumes it exists.
32
+
33
+ A test needs an application that is configured for tests rather than for
34
+ development: an in-memory database, a synchronous queue, a log-driver mailer, a
35
+ known session secret. Building that inline in every file goes stale the first
36
+ time you add a provider, so build it once in `tests/helpers.ts` and import it
37
+ everywhere:
38
+
39
+ ```typescript
40
+ // tests/helpers.ts
41
+ import { Application } from "zerotal";
42
+ import { DatabaseProvider } from "@zerotal/orm";
43
+ import { SessionProvider } from "@zerotal/session";
44
+ import { AuthProvider } from "@zerotal/auth";
45
+ import { createTestApp, type TestApp } from "@zerotal/testing";
46
+
47
+ export function createApp(setup?: () => void): Promise<TestApp> {
48
+ return createTestApp(
49
+ () =>
50
+ Application.create({ env: "test" })
51
+ .register([DatabaseProvider, SessionProvider, AuthProvider])
52
+ .useConfig({
53
+ database: { url: ":memory:" },
54
+ session: { driver: "cookie", secret: "test-secret", cookie: "session", ttl: 7200 },
55
+ queue: { driver: "sync", connection: ":memory:" },
56
+ }),
57
+ setup,
58
+ );
59
+ }
60
+ ```
61
+
62
+ The API template scaffolds this file for you. Add a provider to your app and you
63
+ add it here too — that one edit keeps every test in the suite honest.
64
+
65
+ From then on a test is two lines of setup:
66
+
67
+ ```typescript
68
+ // tests/http/posts.test.ts
69
+ import { test } from "bun:test";
70
+ import { createApp } from "../helpers.ts";
71
+
72
+ test("lists posts", async () => {
73
+ const app = await createApp();
74
+
75
+ const res = await app.get("/posts");
76
+
77
+ res.assertOk();
78
+ await app.close();
79
+ });
80
+ ```
81
+
82
+ > **Note** — `createTestApp(bootstrap, setup?)` takes a **bootstrap callback**,
83
+ > not an application. It resets framework state, calls your callback, adopts the
84
+ > result as the current app, runs `setup`, and starts the server on a random
85
+ > port. Calling it without the callback will not compile.
86
+
87
+ Add a `test` script so the suite runs the same way everywhere:
88
+
89
+ ```json
90
+ // package.json
91
+ {
92
+ "scripts": {
93
+ "test": "bun test"
94
+ }
95
+ }
96
+ ```
97
+
98
+ `bun zt test` runs the same files with `APP_ENV=test` already set, which is
99
+ what you want when a test boots the app through your own `bootstrap/app.ts`
100
+ rather than through `createApp()`.
101
+
102
+ ## The toolkit
103
+
104
+ | Area | What it covers |
105
+ | -------------------------------------- | ------------------------------------------------------------------------------------- |
106
+ | [HTTP Tests](/docs/testing/http) | Boot the app, send requests, assert on `TestResponse`; forms, uploads, auth, session. |
107
+ | [Console Tests](/docs/testing/console) | Run CLI commands in-process with `Artisan.call()` and assert output/exit code. |
108
+ | [Browser Tests](/docs/testing/browser) | End-to-end Playwright tests against a live server (Flow/Inertia UIs). |
109
+ | [Database](/docs/testing/database) | Migrations, transactional rollback per test, and `assertDatabase*`. |
110
+ | [Mocking](/docs/testing/mocking) | Event/queue/notification/broadcast/storage/HTTP fakes, the test clock, and `fake`. |
111
+ | [Flow Tests](/docs/flow/testing) | Drive a component's own lifecycle in-process — no server, no browser. |
112
+ | [Admin Tests](/docs/admin/testing) | Mount a panel resource's List / View / Form pages with resource-aware assertions. |
113
+
114
+ ## Which test should I write?
115
+
116
+ - **Asserting on a route's status, body, or side effects?** Reach for an
117
+ [HTTP test](/docs/testing/http) — it exercises the full request lifecycle through
118
+ a real server.
119
+ - **Testing a form or a file upload?** Still an HTTP test, but send it the way a
120
+ browser does: [`postForm()` or `multipart()`](/docs/testing/http), not a JSON
121
+ `post()`. A JSON body does not travel the same path.
122
+ - **Asserting on rows after an action?** Pair the HTTP test with the
123
+ [`assertDatabase*` helpers](/docs/testing/database) and per-test rollback.
124
+ - **Testing a CLI command?** Use [Console tests](/docs/testing/console) to run it
125
+ in-process and assert on its output.
126
+ - **Testing what a Flow component does?** Use [Flow tests](/docs/flow/testing) —
127
+ they drive the component's real lifecycle without a server or a browser, which
128
+ is much faster than the HTTP or browser route.
129
+ - **Verifying a rendered UI end-to-end?** Use [Browser tests](/docs/testing/browser).
130
+ - **Need to confirm an email/job/notification/event happened without it happening?**
131
+ Install a [fake](/docs/testing/mocking) and assert on what it captured.
132
+ - **Behaviour that depends on time passing?** Freeze the clock with
133
+ [`Carbon.freeze()`](/docs/testing/mocking#time) instead of waiting.
134
+
135
+ ## Generating a test
136
+
137
+ ```bash
138
+ # in your project root
139
+ bun zt make:test PostTest # tests/feature/PostTest.ts
140
+ bun zt make:test SlugTest --unit # tests/unit/SlugTest.ts
141
+ ```
142
+
143
+ The feature stub boots the app through `tests/helpers.ts`, so a generated test
144
+ runs against the same configured application as the rest of the suite rather than
145
+ building its own.
146
+
147
+ ## A first test
148
+
149
+ `createTestApp()` boots your application, starts it on a random port, and returns a
150
+ `TestApp` client. Pair it with a [factory](/docs/orm/factories) to arrange data:
151
+
152
+ ```typescript
153
+ // tests/Feature/PostTest.ts
154
+ import { describe, it, beforeAll, afterAll } from "bun:test";
155
+ import { createTestApp, migrateDatabase, type TestApp, assertDatabaseHas } from "@zerotal/testing";
156
+ import { app } from "../bootstrap/app.ts";
157
+ import { UserFactory } from "../database/factories/UserFactory.ts";
158
+
159
+ let testApp: TestApp;
160
+ beforeAll(async () => {
161
+ testApp = await createTestApp(() => app);
162
+ await migrateDatabase(); // build the schema from database/migrations
163
+ });
164
+ afterAll(() => testApp.close());
165
+
166
+ describe("POST /posts", () => {
167
+ it("creates a post for an authenticated user", async () => {
168
+ const user = await UserFactory.create();
169
+
170
+ const res = await testApp.actingAs(user).post("/posts", { title: "Hello", slug: "hello" });
171
+
172
+ res.assertCreated();
173
+ await assertDatabaseHas("posts", { slug: "hello" });
174
+ });
175
+
176
+ it("rejects a post with no title", async () => {
177
+ const user = await UserFactory.create();
178
+
179
+ const res = await testApp.actingAs(user).asJson().post("/posts", { slug: "hello" });
180
+
181
+ res.assertUnprocessable().assertInvalid("title");
182
+ });
183
+ });
184
+ ```
185
+
186
+ See [HTTP Tests](/docs/testing/http) for the full `TestApp` and `TestResponse` API.
187
+
188
+ > **Tip** — `createTestApp(bootstrap, setup?)` takes an optional second callback
189
+ > that runs after the reset but before the server starts — register test-only
190
+ > routes there so they compile into the server.
191
+
192
+ ## Arranging data
193
+
194
+ - **[Factories](/docs/orm/factories)** — generate model records (`Factory.define`, `create`, `for`, `state`, `count`).
195
+ - **[Seeding](/docs/seeding)** — seed reusable fixtures shared by dev and tests.
196
+ - **[Database](/docs/testing/database)** — keep tests isolated with per-test rollback.
197
+
198
+ ## Resetting framework state
199
+
200
+ ```typescript
201
+ // tests/Feature/SomeTest.ts
202
+ import { resetTestState } from "@zerotal/testing";
203
+
204
+ afterEach(() => resetTestState());
205
+ ```
206
+
207
+ `resetTestState()` disposes the current `Application` and clears the `Router`, ORM
208
+ observers, global scopes, and state-machine callbacks, plus framework event
209
+ subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
210
+ using those helpers don't need the explicit `afterEach`.
211
+
212
+ ## References
213
+
214
+ The most-used members exported from `@zerotal/testing`. Each area's page documents
215
+ its full surface.
216
+
217
+ | Member | Signature | Description |
218
+ | ----------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
219
+ | `createTestApp` | `(bootstrap: () => Application \| Promise<Application>, setup?: () => void) => Promise<TestApp>` | Boot the app on a random port and return a client. |
220
+ | `TestApp#actingAs` | `(user: { id: number \| string }) => this` | Authenticate subsequent requests as `user`. |
221
+ | `TestApp#post` | `(url: string, body: unknown, headers?) => Promise<TestResponse>` | Send a JSON POST request. |
222
+ | `TestApp#close` | `() => Promise<void>` | Stop the server and reset state; call in `afterAll`. |
223
+ | `assertDatabaseHas` | `(table: string, where: Record<string, unknown>) => Promise<void>` | Assert a matching row exists. |
224
+ | `assertDatabaseMissing` | `(table: string, where: Record<string, unknown>) => Promise<void>` | Assert no matching row exists. |
225
+ | `assertDatabaseCount` | `(table: string, expected: number, where?) => Promise<void>` | Assert the row count for a table. |
226
+ | `migrateDatabase` | `(options?: MigrateDatabaseOptions) => Promise<string[]>` | Build the schema from the project's migrations. |
227
+ | `refreshDatabase` | `(options?: RefreshDatabaseOptions) => void` | Wrap each test in a transaction and roll back. |
228
+ | `resetTestState` | `() => void` | Dispose the app and clear framework/ORM state. |
229
+ | `Factory.define` | `(Model, (f) => FactoryPayload<T>) => Factory<T>` | Define a reusable model factory. |
230
+ | `fake` | `typeof fake` | South-African-flavoured random data generator. |
231
+ | `fakeFile` | `typeof fakeFile` | Real PNG/JPEG/GIF/PDF files for upload tests. |
232
+
233
+ ## Next steps
234
+
235
+ - [HTTP Tests](/docs/testing/http) — the full `TestApp` and `TestResponse` API.
236
+ - [Database Tests](/docs/testing/database) — migrations and per-test rollback.
237
+ - [Console Tests](/docs/testing/console) — run CLI commands in-process.
238
+ - [Mocking](/docs/testing/mocking) — fakes for events, queue, notifications, broadcasts, storage, and outbound HTTP, plus the test clock.
239
+ - [Flow Tests](/docs/flow/testing) — drive a component in-process, with no server.
240
+ - [Admin Tests](/docs/admin/testing) — mount a panel resource's pages.
@@ -0,0 +1,410 @@
1
+ ---
2
+ title: Mocking
3
+ description: Swap real services for in-memory fakes so tests can assert side effects without doing real I/O.
4
+ ---
5
+
6
+ # Mocking
7
+
8
+ To test that code _triggers_ a side effect — queues a job, notifies a user,
9
+ broadcasts an event — swap the real service for an in-memory **fake**. The fake
10
+ records each call so you can assert on it, and performs no real I/O.
11
+
12
+ `EventFake`, `QueueFake`, and `NotificationFake` share an `install()` /
13
+ `restore()` lifecycle that swaps a container binding; broadcasting, storage, and
14
+ the HTTP client are faked through their own facades. All record-and-assert without
15
+ touching the network or the disk.
16
+
17
+ > **Danger** — A fake performs no real I/O. If a fake is still installed when a
18
+ > later test expects real delivery, jobs and notifications silently vanish —
19
+ > always restore it in `afterEach`.
20
+
21
+ ## Which fake do I use?
22
+
23
+ - **Emitting a domain event?** Use `EventFake` — captures every `Events.emit()`
24
+ and stops its listeners from running.
25
+ - **Queueing a job?** Use `QueueFake` — captures every `Queue.dispatch()`.
26
+ - **Sending a notification** (including the `mail` channel)? Use
27
+ `NotificationFake` — captures every `Notify.send()` / `Notify.queue()`.
28
+ - **Broadcasting an event** over WebSockets? Use `Broadcast.fake()` — records
29
+ every `Broadcast.send()` / `Broadcast.to()`.
30
+ - **Writing a file to a disk?** Use `Storage.fake()` — an in-memory disk that
31
+ records what was stored.
32
+ - **Calling another service over HTTP?** Use `Http.fake()` — stubs outbound
33
+ requests so no real network call is made.
34
+ - **Signing in through an OAuth provider?** Use `Social.fake()` from
35
+ `@zerotal/auth` — returns a canned profile instead of exchanging a code.
36
+ - **Depending on the passage of time?** Freeze the clock with `Carbon.freeze()`
37
+ rather than installing a fake.
38
+ - **Need filler test data** (names, emails, dates)? Use [`fake`](#generating-fake-data),
39
+ the data generator — it has nothing to do with the service fakes above.
40
+
41
+ Everything except `Broadcast.fake()` and `Social.fake()` is re-exported from
42
+ `@zerotal/testing`, so a test rarely has to know which package a fake ships in.
43
+
44
+ ## Events
45
+
46
+ `EventFake.install()` replaces the `events` binding, so emitted events are
47
+ recorded and their listeners never run.
48
+
49
+ ```typescript
50
+ // src/tests/PostPublishTest.ts
51
+ import { EventFake } from "@zerotal/testing";
52
+
53
+ let events: EventFake;
54
+ beforeEach(() => {
55
+ events = EventFake.install();
56
+ });
57
+ afterEach(() => events.restore());
58
+
59
+ it("announces that a post was published", async () => {
60
+ const post = await PostFactory.create({ status: "draft" });
61
+
62
+ await post.publish();
63
+
64
+ events.assertEmitted(PostPublished);
65
+ events.assertEmitted(PostPublished, (e) => e.postId === post.id);
66
+ events.assertNotEmitted(PostDeleted);
67
+ events.assertEmittedCount(PostPublished, 1);
68
+ });
69
+ ```
70
+
71
+ Faking the emitter is what lets this test assert that publishing _announces_
72
+ itself without also running everything that reacts to the announcement — mail,
73
+ search indexing, cache invalidation.
74
+
75
+ > **Warning** — To test a listener, do not fake. Construct the listener and hand
76
+ > it an event directly; the fake stops listeners from running at all.
77
+
78
+ ## Queue
79
+
80
+ `QueueFake.install()` replaces the `queue` binding so dispatched jobs are
81
+ captured instead of persisted or executed.
82
+
83
+ ```typescript
84
+ // src/tests/PostPublishTest.ts
85
+ import { QueueFake } from "@zerotal/testing"; // re-exported from @zerotal/queue
86
+
87
+ let queue: QueueFake;
88
+ beforeEach(() => {
89
+ queue = QueueFake.install();
90
+ });
91
+ afterEach(() => queue.restore());
92
+
93
+ it("queues a job when a post is published", async () => {
94
+ const post = await PostFactory.create({ status: "draft" });
95
+ await post.transitionTo("published");
96
+
97
+ queue.assertDispatched(NotifySubscribersJob);
98
+ queue.assertDispatched(NotifySubscribersJob, (j) => j.postId === post.id);
99
+ queue.assertNotDispatched(ProcessPaymentJob);
100
+ queue.assertDispatchedCount(1);
101
+ queue.assertNothingDispatched(); // for isolation tests
102
+ });
103
+ ```
104
+
105
+ > **Warning** — The fake captures the job but never calls its `handle()`. To test
106
+ > the job's body, instantiate and `await` it directly instead of faking.
107
+
108
+ ## Notifications
109
+
110
+ `NotificationFake.install()` replaces the `notifications` binding. Both
111
+ `Notify.send()` and `Notify.queue()` are captured as "sent". The `mail` channel
112
+ runs through `Notify`, so faking notifications also covers email side effects.
113
+
114
+ ```typescript
115
+ // src/tests/InvoiceTest.ts
116
+ import { NotificationFake } from "@zerotal/testing"; // re-exported from @zerotal/notifications
117
+ import { Notify } from "@zerotal/notifications";
118
+
119
+ let notify: NotificationFake;
120
+ beforeEach(() => {
121
+ notify = NotificationFake.install();
122
+ });
123
+ afterEach(() => notify.restore());
124
+
125
+ it("notifies the user when an invoice is ready", async () => {
126
+ const user = await UserFactory.create();
127
+ const invoice = await InvoiceFactory.create({ userId: user.id });
128
+
129
+ await Notify.send(user, new InvoiceReady(invoice));
130
+
131
+ notify.assertSentTo(user, InvoiceReady);
132
+ notify.assertSentTo(user, InvoiceReady, (n) => n.invoiceId === invoice.id);
133
+ notify.assertNotSentTo(adminUser, InvoiceReady);
134
+ notify.assertSentCount(1);
135
+ notify.assertNothingSent(); // for isolation tests
136
+ });
137
+ ```
138
+
139
+ ## Broadcasting
140
+
141
+ The `Broadcast` facade has its own recorder. Call `Broadcast.fake()` to install
142
+ it and `Broadcast.resetFake()` to restore — the returned fake exposes the
143
+ assertions:
144
+
145
+ ```typescript
146
+ // src/tests/PostBroadcastTest.ts
147
+ import { Broadcast } from "@zerotal/broadcasting";
148
+
149
+ const fake = Broadcast.fake();
150
+
151
+ await PostController.publish({ http: ctx });
152
+
153
+ fake.assertBroadcast("PostPublished", "posts");
154
+ fake.assertBroadcast("PostPublished", "posts", { id: post.id }); // partial payload match
155
+ fake.assertNothingBroadcast();
156
+
157
+ Broadcast.resetFake();
158
+ ```
159
+
160
+ `assertBroadcast(eventName, channel?, data?)` matches on the broadcast-as name
161
+ first, then the channel, then a partial payload. See
162
+ [Broadcasting › Testing](/docs/broadcasting/testing) for the full assertion set.
163
+
164
+ ## Storage
165
+
166
+ `Storage.fake()` swaps a disk for an in-memory one and returns it. Nothing
167
+ touches the filesystem, so each test starts empty by construction rather than by
168
+ remembering to clean up — and a suite that forgets to clean up passes the second
169
+ time for the wrong reason.
170
+
171
+ ```typescript
172
+ // src/tests/AvatarTest.ts
173
+ import { Storage } from "zerotal/storage";
174
+ import { fakeFile } from "@zerotal/testing";
175
+
176
+ let disk: ReturnType<typeof Storage.fake>;
177
+ beforeEach(() => {
178
+ disk = Storage.fake(); // or Storage.fake('s3') for a named disk
179
+ });
180
+ afterEach(() => Storage.restoreFakes());
181
+
182
+ it("stores an uploaded avatar", async () => {
183
+ const res = await app.actingAs(user).multipart("/avatar", {
184
+ avatar: fakeFile.image("me.png"),
185
+ });
186
+
187
+ res.assertCreated();
188
+ disk.assertExistsMatching(/^avatars\/[0-9a-f-]+\.png$/);
189
+ disk.assertContentType(disk.paths()[0]!, "image/png");
190
+ disk.assertCount(1);
191
+ });
192
+ ```
193
+
194
+ Uploads are stored under a generated name, which is why `assertExistsMatching`
195
+ takes a pattern. When you control the path, `assertExists(path, contents?)` checks
196
+ it directly.
197
+
198
+ ## Outbound HTTP
199
+
200
+ `Http.fake()` intercepts requests made through the [`Http`](/docs/helpers)
201
+ client, so a test that exercises a service integration makes no real network
202
+ call — and does not fail when that service is down or rate-limits you.
203
+
204
+ ```typescript
205
+ // src/tests/PaymentTest.ts
206
+ import { Http } from "@zerotal/testing";
207
+
208
+ beforeEach(() => {
209
+ Http.fake([
210
+ { url: "https://api.payments.test/charges", body: { id: "ch_1", status: "paid" } },
211
+ { url: "*", status: 404 }, // anything else is a mistake, and says so
212
+ ]);
213
+ });
214
+ afterEach(() => Http.resetFakes());
215
+
216
+ it("charges the card and records the reference", async () => {
217
+ await checkout.pay(order);
218
+
219
+ Http.assertSent((req) => req.url.includes("/charges") && req.method === "POST");
220
+ Http.assertSentCount(1);
221
+ await assertDatabaseHas("orders", { payment_reference: "ch_1" });
222
+ });
223
+ ```
224
+
225
+ A catch-all stub at the end is worth adding: without it an unstubbed URL falls
226
+ through, and a test can quietly start depending on a service being reachable.
227
+
228
+ ## Time
229
+
230
+ Behaviour that depends on the passage of time — a token that expires in seven
231
+ days, a reminder that only fires after 24 hours — has no way to be tested except
232
+ by waiting. Freeze the clock instead:
233
+
234
+ ```typescript
235
+ // src/tests/InvitationTest.ts
236
+ import { Carbon } from "zerotal";
237
+
238
+ afterEach(() => Carbon.release());
239
+
240
+ it("expires an invitation after seven days", async () => {
241
+ Carbon.freeze("2025-01-01T00:00:00Z");
242
+ const invitation = await InvitationFactory.create();
243
+
244
+ expect(invitation.isExpired()).toBe(false);
245
+
246
+ Carbon.travel({ days: 8 });
247
+
248
+ expect(invitation.isExpired()).toBe(true);
249
+ });
250
+ ```
251
+
252
+ | Member | Purpose |
253
+ | -------------------------- | ------------------------------------------------------------ |
254
+ | `Carbon.freeze(value?)` | Freeze at `value`, or at the current instant. |
255
+ | `Carbon.setTestNow(value)` | Freeze at an absolute point; `null` releases. |
256
+ | `Carbon.travelTo(value)` | Jump to an absolute point. |
257
+ | `Carbon.travel(amount)` | Move relative to now; negative values go back. |
258
+ | `Carbon.withTestNow(v, f)` | Freeze for the duration of `f`, releasing even if it throws. |
259
+ | `Carbon.release()` | Let the clock run normally again. |
260
+ | `Carbon.isFrozen()` | Whether the clock is currently frozen. |
261
+
262
+ > **Danger** — Always release the clock in an `afterEach`. A frozen clock that
263
+ > outlives its test makes the next one fail somewhere unrelated.
264
+
265
+ This moves Carbon's clock, which is what `Carbon.now()` and everything built on
266
+ it read — `isPast`, `isToday`, `diffForHumans`, a model's timestamps. A raw
267
+ `Date.now()` is unaffected, so code that must be testable against time should go
268
+ through [Carbon](/docs/carbon).
269
+
270
+ ## Generating fake data
271
+
272
+ `fake` is a zero-dependency, South-African-flavoured data generator for filling
273
+ in test values — used inside [factories](/docs/orm/factories) or directly in a
274
+ test. It is unrelated to the service fakes above.
275
+
276
+ ```typescript
277
+ // src/tests/example.ts
278
+ import { fake } from "@zerotal/testing";
279
+
280
+ // Identity
281
+ fake.name(); // "Sipho Dlamini"
282
+ fake.email(); // "sipho.dlamini73@gmail.com"
283
+ fake.email({ corporate: true }); // "sipho.dlamini@shoprite.co.za"
284
+ fake.phone(); // "071 234 5678"
285
+
286
+ // Location & business
287
+ fake.city();
288
+ fake.province();
289
+ fake.suburb();
290
+ fake.streetAddress();
291
+ fake.postalCode();
292
+ fake.company();
293
+ fake.jobTitle();
294
+ fake.department();
295
+
296
+ // Primitives
297
+ fake.string(10); // random alphanumeric
298
+ fake.number(1, 1000); // integer in [1, 1000]
299
+ fake.float(0, 1, 2); // 2-decimal float
300
+ fake.boolean(0.8); // true 80% of the time
301
+ fake.uuid();
302
+ fake.maybe("val", 0.5); // value or null
303
+
304
+ // Dates
305
+ fake.date();
306
+ fake.pastDate(3);
307
+ fake.futureDate(2);
308
+ fake.isoDate();
309
+ fake.timestamp();
310
+
311
+ // Text
312
+ fake.word();
313
+ fake.words(5);
314
+ fake.sentence();
315
+ fake.paragraph();
316
+ fake.title();
317
+ fake.slug("My Title");
318
+
319
+ // Arrays
320
+ fake.pick([1, 2, 3]); // random element
321
+ fake.shuffle([1, 2, 3]); // shuffled copy
322
+ fake.sample([1, 2, 3, 4], 2); // 2 unique random picks
323
+ ```
324
+
325
+ ## References
326
+
327
+ `EventFake` — captures emitted events:
328
+
329
+ | Method | Signature | Description |
330
+ | ---------------------- | --------------------------------------------- | --------------------------------------------------------- |
331
+ | `install` | `static install(): EventFake` | Swap the `events` binding for the fake. |
332
+ | `restore` | `restore(): void` | Restore the original `events` binding. |
333
+ | `emitted` | `emitted(): object[]` | All captured events, in emit order. |
334
+ | `emittedOf` | `emittedOf(EventClass): T[]` | The captured events of one class, typed. |
335
+ | `assertEmitted` | `assertEmitted(EventClass, filter?): void` | An event of that class was emitted (optionally matching). |
336
+ | `assertNotEmitted` | `assertNotEmitted(EventClass, filter?): void` | No such event was emitted. |
337
+ | `assertEmittedCount` | `assertEmittedCount(EventClass, count): void` | Exactly `count` events of that class were emitted. |
338
+ | `assertNothingEmitted` | `assertNothingEmitted(): void` | No events were emitted at all. |
339
+ | `clear` | `clear(): void` | Discard what has been captured so far. |
340
+
341
+ `FakeDisk` — returned by `Storage.fake()`:
342
+
343
+ | Method | Signature | Description |
344
+ | ---------------------- | --------------------------------------------- | ------------------------------------------------------ |
345
+ | `assertExists` | `assertExists(path, contents?): this` | A file is stored at `path` (optionally with contents). |
346
+ | `assertMissing` | `assertMissing(path): this` | Nothing is stored at `path`. |
347
+ | `assertExistsMatching` | `assertExistsMatching(pattern: RegExp): this` | Some stored path matches `pattern`. |
348
+ | `assertContentType` | `assertContentType(path, type): this` | The file was stored with that content type. |
349
+ | `assertCount` | `assertCount(expected): this` | Exactly `expected` files are stored. |
350
+ | `assertNothingStored` | `assertNothingStored(): this` | Nothing was stored. |
351
+ | `paths` / `file` | `paths(): string[]` / `file(path)` | Inspect what is stored. |
352
+ | `clear` | `clear(): void` | Empty the disk. |
353
+
354
+ `Http` — the outbound HTTP client's own harness:
355
+
356
+ | Method | Signature | Description |
357
+ | ------------------- | ------------------------------------ | --------------------------------------------------- |
358
+ | `fake` | `static fake(stubs?): void` | Intercept outbound requests with stubbed responses. |
359
+ | `resetFakes` | `static resetFakes(): void` | Restore real HTTP behaviour. |
360
+ | `recorded` | `static recorded(): Array<…>` | Every request recorded since `fake()`. |
361
+ | `assertSent` | `static assertSent(predicate): void` | A matching request was sent. |
362
+ | `assertNotSent` | `static assertNotSent(pred): void` | No matching request was sent. |
363
+ | `assertSentCount` | `static assertSentCount(n): void` | Exactly `n` requests were sent. |
364
+ | `assertNothingSent` | `static assertNothingSent(): void` | No requests were sent at all. |
365
+
366
+ `QueueFake` — captures dispatched jobs:
367
+
368
+ | Method | Signature | Description |
369
+ | ------------------------- | ------------------------------------------- | ------------------------------------------------------------------ |
370
+ | `install` | `static install(): QueueFake` | Swap the `queue` binding for the fake. |
371
+ | `restore` | `restore(): void` | Restore the original `queue` binding. |
372
+ | `dispatched` | `dispatched(): Job[]` | All captured jobs. |
373
+ | `assertDispatched` | `assertDispatched(JobClass, filter?): void` | A job of that class was dispatched (optionally matching `filter`). |
374
+ | `assertNotDispatched` | `assertNotDispatched(JobClass): void` | No job of that class was dispatched. |
375
+ | `assertNothingDispatched` | `assertNothingDispatched(): void` | No jobs were dispatched at all. |
376
+ | `assertDispatchedCount` | `assertDispatchedCount(count): void` | Exactly `count` jobs were dispatched. |
377
+
378
+ `NotificationFake` — captures sent notifications:
379
+
380
+ | Method | Signature | Description |
381
+ | ------------------- | ------------------------------------------------------------ | -------------------------------------------------------- |
382
+ | `install` | `static install(): NotificationFake` | Swap the `notifications` binding for the fake. |
383
+ | `restore` | `restore(): void` | Restore the original `notifications` binding. |
384
+ | `sent` | `sent(): CapturedNotification[]` | All captured `{ notifiable, notification }` pairs. |
385
+ | `assertSentTo` | `assertSentTo(notifiable, NotificationClass, filter?): void` | A notification of that class was sent to the notifiable. |
386
+ | `assertNotSentTo` | `assertNotSentTo(notifiable, NotificationClass): void` | That class was not sent to the notifiable. |
387
+ | `assertNothingSent` | `assertNothingSent(): void` | No notifications were sent at all. |
388
+ | `assertSentCount` | `assertSentCount(count): void` | Exactly `count` notifications were sent. |
389
+
390
+ `BroadcastFake` — returned by `Broadcast.fake()`:
391
+
392
+ | Method | Signature | Description |
393
+ | ------------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------ |
394
+ | `recorded` | `recorded(): RecordedBroadcast[]` | All recorded `{ channel, event, data }` broadcasts. |
395
+ | `reset` | `reset(): void` | Clear recorded broadcasts. |
396
+ | `assertBroadcast` | `assertBroadcast(eventName, channel?, data?): void` | An event was broadcast (optionally on `channel`, with a partial `data` match). |
397
+ | `assertNotBroadcast` | `assertNotBroadcast(eventName, channel?): void` | That event was not broadcast. |
398
+ | `assertNothingBroadcast` | `assertNothingBroadcast(): void` | No broadcasts were recorded. |
399
+ | `assertBroadcastCount` | `assertBroadcastCount(count): void` | Exactly `count` broadcasts were recorded. |
400
+
401
+ ## Next steps
402
+
403
+ - [Factories](/docs/orm/factories) — where `fake` is most often used.
404
+ - [Events](/docs/events) — the real emitter `EventFake` stands in for.
405
+ - [Queue](/docs/queue) — the real queue `QueueFake` stands in for.
406
+ - [Notifications](/docs/notifications) — the real notifier `NotificationFake` stands in for, including the `mail` channel.
407
+ - [Storage](/docs/storage) — the disks `Storage.fake()` stands in for.
408
+ - [Carbon](/docs/carbon) — the date-time API whose clock the time helpers move.
409
+ - [Console Tests](/docs/testing/console) — assert side effects of CLI commands.
410
+ - [Database](/docs/testing/database) — assert rows alongside faked side effects.