@zerotal/arch 1.7.4 → 1.8.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.
- package/CHANGELOG.md +86 -2
- package/README.md +4 -2
- package/api-surface.md +2 -0
- package/docs/about.md +16 -16
- package/docs/admin/actions.md +9 -9
- package/docs/admin/auth.md +2 -2
- package/docs/admin/dashboard.md +5 -5
- package/docs/admin/extending-ui.md +12 -12
- package/docs/admin/extending.md +16 -7
- package/docs/admin/forms.md +4 -4
- package/docs/admin/index.md +2 -2
- package/docs/admin/operations.md +9 -9
- package/docs/admin/resources.md +3 -3
- package/docs/admin/structure.md +6 -6
- package/docs/admin/tables.md +8 -8
- package/docs/admin/testing.md +4 -4
- package/docs/ai.md +118 -11
- package/docs/application.md +12 -12
- package/docs/arch.md +25 -3
- package/docs/assets.md +11 -11
- package/docs/audit.md +13 -13
- package/docs/authentication.md +39 -39
- package/docs/authorization.md +13 -13
- package/docs/broadcasting/channels.md +2 -2
- package/docs/broadcasting/client.md +2 -2
- package/docs/broadcasting/events.md +9 -10
- package/docs/broadcasting/index.md +1 -1
- package/docs/broadcasting/testing.md +5 -5
- package/docs/cache.md +15 -13
- package/docs/carbon.md +35 -35
- package/docs/changelog.md +268 -1
- package/docs/client/index.md +13 -13
- package/docs/commands.md +36 -4
- package/docs/components.md +116 -115
- package/docs/config-system.md +4 -4
- package/docs/container.md +27 -27
- package/docs/context.md +27 -27
- package/docs/contributing.md +28 -0
- package/docs/controllers.md +10 -10
- package/docs/conventions.md +23 -15
- package/docs/cookies.md +3 -3
- package/docs/csrf.md +6 -6
- package/docs/database.md +22 -17
- package/docs/deployment.md +99 -2
- package/docs/devtools.md +16 -16
- package/docs/email-verification.md +8 -8
- package/docs/encryption.md +7 -7
- package/docs/errors.md +6 -6
- package/docs/events.md +12 -11
- package/docs/flow/components.md +32 -32
- package/docs/flow/decorators.md +18 -18
- package/docs/flow/events.md +16 -16
- package/docs/flow/forms.md +17 -17
- package/docs/flow/icons.md +5 -5
- package/docs/flow/index.md +21 -21
- package/docs/flow/layouts.md +99 -35
- package/docs/flow/lifecycle.md +16 -16
- package/docs/flow/models.md +9 -9
- package/docs/flow/pagination.md +8 -8
- package/docs/flow/performance.md +7 -7
- package/docs/flow/references.md +3 -3
- package/docs/flow/routing.md +26 -26
- package/docs/flow/testing.md +20 -20
- package/docs/getting-started.md +18 -13
- package/docs/health.md +17 -8
- package/docs/helpers.md +17 -17
- package/docs/i18n.md +10 -10
- package/docs/inertia/devtools.md +4 -4
- package/docs/inertia/index.md +6 -6
- package/docs/inertia/props.md +21 -21
- package/docs/inertia/rendering.md +9 -9
- package/docs/inertia/ssr.md +2 -2
- package/docs/lifecycle.md +2 -2
- package/docs/lock.md +11 -11
- package/docs/logger.md +12 -12
- package/docs/media.md +22 -22
- package/docs/middleware.md +17 -17
- package/docs/migrations.md +13 -13
- package/docs/monitor.md +6 -6
- package/docs/notifications.md +24 -24
- package/docs/orm/casts.md +14 -14
- package/docs/orm/factories.md +8 -8
- package/docs/orm/index.md +22 -20
- package/docs/orm/lifecycle.md +13 -13
- package/docs/orm/queries.md +28 -28
- package/docs/orm/relationships.md +16 -16
- package/docs/orm/serialization.md +11 -11
- package/docs/package-development.md +10 -10
- package/docs/pagination.md +14 -14
- package/docs/password-reset.md +6 -6
- package/docs/providers.md +11 -11
- package/docs/query-builder.md +18 -18
- package/docs/queue.md +14 -12
- package/docs/rate-limiting.md +6 -6
- package/docs/responses.md +14 -14
- package/docs/roles-and-2fa.md +9 -9
- package/docs/routing.md +44 -44
- package/docs/scaffolding.md +1 -1
- package/docs/scheduler.md +10 -10
- package/docs/seeding.md +6 -6
- package/docs/session.md +16 -14
- package/docs/social.md +14 -14
- package/docs/storage.md +25 -13
- package/docs/structure.md +2 -2
- package/docs/support-policy.md +23 -10
- package/docs/telemetry.md +12 -12
- package/docs/tenancy.md +34 -40
- package/docs/testing/browser.md +6 -6
- package/docs/testing/console.md +3 -3
- package/docs/testing/database.md +10 -10
- package/docs/testing/flow-browser.md +6 -6
- package/docs/testing/http.md +12 -12
- package/docs/testing/index.md +29 -3
- package/docs/testing/mocking.md +8 -8
- package/docs/upgrade.md +1 -1
- package/docs/validator.md +19 -19
- package/docs/view.md +14 -14
- package/package.json +4 -4
- package/src/config.ts +8 -0
- package/src/index.ts +25 -1
- package/src/install/ArchInstallCommand.ts +46 -3
- package/src/install/guidelines.ts +84 -0
- package/src/install/shape.ts +177 -0
- package/src/install/skills.ts +271 -0
- package/src/provider/ArchProvider.ts +148 -0
- package/src/tools/searchDocs.ts +7 -0
package/docs/tenancy.md
CHANGED
|
@@ -32,7 +32,7 @@ The package owns the `tenants` registry and the `tenant_members` pivot tables
|
|
|
32
32
|
|
|
33
33
|
Supply the config with `TenancyProvider.withConfig(...)`, then add the provider to the array in `bootstrap/providers.ts`:
|
|
34
34
|
|
|
35
|
-
```typescript
|
|
35
|
+
```typescript fragment
|
|
36
36
|
// bootstrap/providers.ts
|
|
37
37
|
import { TenancyProvider } from "@zerotal/tenancy";
|
|
38
38
|
import tenancyConfig from "../config/tenancy.ts";
|
|
@@ -141,7 +141,7 @@ import { RouteParamResolver } from "@zerotal/tenancy";
|
|
|
141
141
|
new RouteParamResolver({ param: "tenancy" }); // default param name
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
```typescript
|
|
144
|
+
```typescript fragment
|
|
145
145
|
// bootstrap/app.ts
|
|
146
146
|
.fileBasedRouting({
|
|
147
147
|
dir: basePath("app/routes"),
|
|
@@ -178,7 +178,7 @@ Requests with no authenticated user — login, signup, a public marketing page
|
|
|
178
178
|
resolve to `null` here, and with `strict: false` (the default) fall through to
|
|
179
179
|
the default connection rather than failing.
|
|
180
180
|
|
|
181
|
-
```typescript
|
|
181
|
+
```typescript fragment
|
|
182
182
|
// config/tenancy.ts — a non-default column
|
|
183
183
|
new AuthResolver({ column: "organisationId" });
|
|
184
184
|
```
|
|
@@ -208,28 +208,21 @@ export class CookieResolver implements TenantResolver {
|
|
|
208
208
|
|
|
209
209
|
`TenancyMiddleware` resolves the tenant and opens the [TenantContext](#tenantcontext-access-the-tenant-anywhere) boundary. Apply it globally or to specific route groups.
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
Registering `TenancyProvider` is the whole of it — the provider adds the resolving
|
|
212
|
+
middleware to the global pipeline itself, so every request has a tenant boundary
|
|
213
|
+
opened before it reaches a route:
|
|
214
214
|
|
|
215
|
-
```typescript
|
|
215
|
+
```typescript fragment
|
|
216
216
|
// bootstrap/app.ts
|
|
217
|
-
import {
|
|
217
|
+
import { TenancyProvider } from "@zerotal/tenancy";
|
|
218
218
|
|
|
219
|
-
Application.create({ providers
|
|
219
|
+
Application.create({ providers: [TenancyProvider] });
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
import { TenancyMiddleware } from "@zerotal/tenancy";
|
|
227
|
-
|
|
228
|
-
Router.group({ domain: "{tenant}.myapp.com" }, () => {
|
|
229
|
-
Router.get("/dashboard", DashboardController, "index");
|
|
230
|
-
Router.get("/settings", SettingsController, "index");
|
|
231
|
-
}).use([TenancyMiddleware]);
|
|
232
|
-
```
|
|
222
|
+
`TenancyMiddleware` is deliberately not exported: there is no arrangement in which
|
|
223
|
+
an app registers it by hand, and one registered twice would resolve the tenant
|
|
224
|
+
twice. To _require_ a tenant on particular routes, reach for
|
|
225
|
+
`EnsureTenancyMiddleware` below.
|
|
233
226
|
|
|
234
227
|
The middleware throws an [error](/docs/errors) the framework's exception handler renders by content negotiation:
|
|
235
228
|
|
|
@@ -247,21 +240,22 @@ one. Use it on routes that make no sense without a tenant — it throws
|
|
|
247
240
|
`TenantNotFoundError` when the context is empty rather than letting a query run
|
|
248
241
|
unscoped:
|
|
249
242
|
|
|
250
|
-
```typescript
|
|
243
|
+
```typescript fragment
|
|
251
244
|
// routes/web.ts
|
|
252
|
-
import {
|
|
245
|
+
import { EnsureTenancyMiddleware } from "@zerotal/tenancy";
|
|
253
246
|
|
|
254
|
-
Router.group({ middleware: [
|
|
247
|
+
Router.group({ middleware: [EnsureTenancyMiddleware] }, () => {
|
|
255
248
|
Router.get("/billing", BillingController, "index");
|
|
256
249
|
});
|
|
257
250
|
```
|
|
258
251
|
|
|
259
|
-
|
|
260
|
-
|
|
252
|
+
It runs after the global resolving middleware, which the provider has already
|
|
253
|
+
registered, so by the time this asserts there is a tenant the attempt to find one
|
|
254
|
+
has been made.
|
|
261
255
|
|
|
262
256
|
## ORM scoping — Tenantable
|
|
263
257
|
|
|
264
|
-
Compose the `Tenantable` mixin via [`Model.using`](/docs/orm
|
|
258
|
+
Compose the `Tenantable` mixin via [`Model.using`](/docs/orm#composing-model-mixins) onto any model that belongs to a tenant. Two things happen automatically:
|
|
265
259
|
|
|
266
260
|
1. Every query on the model receives `WHERE tenant_id = <current tenant id>` (skipped outside a tenant context).
|
|
267
261
|
2. `create()` / `save()` inject `tenant_id` on new records, so you never accidentally write cross-tenant data.
|
|
@@ -278,7 +272,7 @@ export class Project extends Model.using(Tenantable) {
|
|
|
278
272
|
}
|
|
279
273
|
```
|
|
280
274
|
|
|
281
|
-
```typescript
|
|
275
|
+
```typescript fragment
|
|
282
276
|
// in a controller — inside a TenancyMiddleware boundary (tenant id = 7):
|
|
283
277
|
const projects = await Project.all();
|
|
284
278
|
// → SELECT * FROM projects WHERE tenant_id = 7
|
|
@@ -312,14 +306,14 @@ export class Invoice extends Model.using(Tenantable) {
|
|
|
312
306
|
|
|
313
307
|
### Single query
|
|
314
308
|
|
|
315
|
-
```typescript
|
|
309
|
+
```typescript fragment
|
|
316
310
|
// in an admin controller — load every project across all tenants:
|
|
317
311
|
const all = await Project.query().withoutTenancy().get();
|
|
318
312
|
```
|
|
319
313
|
|
|
320
314
|
`withoutTenancy()` removes only the tenant scope. To strip every global scope instead:
|
|
321
315
|
|
|
322
|
-
```typescript
|
|
316
|
+
```typescript fragment
|
|
323
317
|
// in a controller
|
|
324
318
|
const all = await Project.query().withoutGlobalScopes().get();
|
|
325
319
|
```
|
|
@@ -328,7 +322,7 @@ const all = await Project.query().withoutGlobalScopes().get();
|
|
|
328
322
|
|
|
329
323
|
Wrap the work in a tenant boundary with `TenantContext.run()` (or `Tenant.run()` via the facade) to scope every query inside it to a specific tenant:
|
|
330
324
|
|
|
331
|
-
```typescript
|
|
325
|
+
```typescript fragment
|
|
332
326
|
// app/jobs/SendReminders.ts
|
|
333
327
|
import { TenantContext, Tenant } from "@zerotal/tenancy";
|
|
334
328
|
|
|
@@ -375,7 +369,7 @@ if (Tenant.check()) {
|
|
|
375
369
|
|
|
376
370
|
Controllers also receive the resolved tenant directly on the request context:
|
|
377
371
|
|
|
378
|
-
```typescript
|
|
372
|
+
```typescript fragment
|
|
379
373
|
// in a controller
|
|
380
374
|
async index(ctx: HttpContext): Promise<void> {
|
|
381
375
|
const tenant = (ctx as any).tenant as Tenant;
|
|
@@ -387,7 +381,7 @@ async index(ctx: HttpContext): Promise<void> {
|
|
|
387
381
|
|
|
388
382
|
The package maintains a `tenant_members` pivot linking your authenticated [User](/docs/authentication) model to tenants, with an admin flag. `Tenancy.create()` makes the current user the first admin; `update()` and `delete()` are admin-gated (use the `force*` variants to bypass the check from trusted server code).
|
|
389
383
|
|
|
390
|
-
```typescript
|
|
384
|
+
```typescript fragment
|
|
391
385
|
// in a controller
|
|
392
386
|
import { Tenant } from "@zerotal/tenancy";
|
|
393
387
|
|
|
@@ -403,7 +397,7 @@ await Tenant.update({ name: "Acme Corp" }); // throws TenantForbiddenError unles
|
|
|
403
397
|
|
|
404
398
|
`tenantDisk()` wraps any [storage](/docs/storage) disk and prefixes every path with `tenants/<slug>/` for the active tenant:
|
|
405
399
|
|
|
406
|
-
```typescript
|
|
400
|
+
```typescript fragment
|
|
407
401
|
// in a controller — inside a tenant boundary (slug = 'acme')
|
|
408
402
|
import { tenantDisk } from "@zerotal/tenancy";
|
|
409
403
|
|
|
@@ -422,7 +416,7 @@ await tenantDisk("s3").put("reports/q4.pdf", pdf);
|
|
|
422
416
|
A key that tries to climb out of its tenant's folder is rejected with
|
|
423
417
|
`TenantStoragePathError` before it reaches the driver:
|
|
424
418
|
|
|
425
|
-
```typescript
|
|
419
|
+
```typescript fragment
|
|
426
420
|
await tenantDisk().put("../other-tenant/secrets.txt", buffer);
|
|
427
421
|
// throws TenantStoragePathError
|
|
428
422
|
```
|
|
@@ -437,7 +431,7 @@ the exception handler renders it like any other.
|
|
|
437
431
|
|
|
438
432
|
`tenantCache()` wraps the [cache](/docs/cache) and prefixes every key with `tenant:<slug>:`:
|
|
439
433
|
|
|
440
|
-
```typescript
|
|
434
|
+
```typescript fragment
|
|
441
435
|
// in a controller — inside a tenant boundary (slug = 'acme')
|
|
442
436
|
import { tenantCache } from "@zerotal/tenancy";
|
|
443
437
|
|
|
@@ -467,7 +461,7 @@ export default TenancyConfig({
|
|
|
467
461
|
});
|
|
468
462
|
```
|
|
469
463
|
|
|
470
|
-
```typescript
|
|
464
|
+
```typescript fragment
|
|
471
465
|
// in a controller — Project.all() automatically uses the active tenant's connection:
|
|
472
466
|
const projects = await Project.all();
|
|
473
467
|
```
|
|
@@ -494,7 +488,7 @@ Set your suite up once as described in [Testing](/docs/testing). Tenancy has one
|
|
|
494
488
|
test that matters more than all the others: **a tenant must not see another
|
|
495
489
|
tenant's rows.** Write it first, and write it as a negative.
|
|
496
490
|
|
|
497
|
-
```typescript
|
|
491
|
+
```typescript fragment
|
|
498
492
|
// tests/tenancy/isolation.test.ts
|
|
499
493
|
import { test, expect } from "bun:test";
|
|
500
494
|
import { Tenancy, TenantContext } from "@zerotal/tenancy";
|
|
@@ -523,7 +517,7 @@ when the query returns everything but that one title.
|
|
|
523
517
|
broken resolver serves the wrong tenant's data correctly, which no isolation test
|
|
524
518
|
catches:
|
|
525
519
|
|
|
526
|
-
```typescript
|
|
520
|
+
```typescript fragment
|
|
527
521
|
// tests/tenancy/resolver.test.ts
|
|
528
522
|
const res = await app.get("/dashboard", { Host: "acme.example.test" });
|
|
529
523
|
|
|
@@ -534,7 +528,7 @@ expect(res.json().tenant).toBe("acme");
|
|
|
534
528
|
tenant and throws leaves it set, and the next test passes or fails for reasons
|
|
535
529
|
that have nothing to do with it:
|
|
536
530
|
|
|
537
|
-
```typescript
|
|
531
|
+
```typescript fragment
|
|
538
532
|
// tests/tenancy/isolation.test.ts
|
|
539
533
|
afterEach(() => {
|
|
540
534
|
expect(TenantContext.tryGet()).toBeUndefined(); // catches a leak at its source
|
|
@@ -601,7 +595,7 @@ Resolved from the container binding `"tenancy"`; the `Tenant` value is the facad
|
|
|
601
595
|
|
|
602
596
|
## Next steps
|
|
603
597
|
|
|
604
|
-
- [ORM](/docs/orm
|
|
598
|
+
- [ORM](/docs/orm) — how `Tenantable` composes onto your models via `Model.using`.
|
|
605
599
|
- [Storage](/docs/storage) — the disks `tenantDisk()` wraps.
|
|
606
600
|
- [Cache](/docs/cache) — the stores `tenantCache()` wraps.
|
|
607
601
|
- [Middleware](/docs/middleware) — where `TenancyMiddleware` runs in the pipeline.
|
package/docs/testing/browser.md
CHANGED
|
@@ -37,7 +37,7 @@ bunx playwright install --with-deps
|
|
|
37
37
|
Add a `playwright.config.ts` that boots your app with the `webServer` option, so the
|
|
38
38
|
server starts once for the whole run and is torn down after:
|
|
39
39
|
|
|
40
|
-
```typescript
|
|
40
|
+
```typescript fragment
|
|
41
41
|
// playwright.config.ts
|
|
42
42
|
import { defineConfig, devices } from "@playwright/test";
|
|
43
43
|
|
|
@@ -81,18 +81,18 @@ Wire up scripts in `package.json`:
|
|
|
81
81
|
Specs live in `e2e/` and use Playwright's `test`/`expect`. Drive the page through
|
|
82
82
|
roles and assert on what the user sees:
|
|
83
83
|
|
|
84
|
-
```typescript
|
|
84
|
+
```typescript fragment
|
|
85
85
|
// e2e/navigate.e2e.ts
|
|
86
86
|
import { test, expect } from "@playwright/test";
|
|
87
87
|
|
|
88
88
|
test.describe("SPA navigation", () => {
|
|
89
89
|
test("swaps content without a full page reload", async ({ page }) => {
|
|
90
|
-
await page.goto("/
|
|
90
|
+
await page.goto("/demo/counter");
|
|
91
91
|
// A full reload would clear this marker; an SPA swap keeps it.
|
|
92
92
|
await page.evaluate(() => ((window as any).__spa = true));
|
|
93
93
|
|
|
94
94
|
await page.getByRole("link", { name: "Components" }).click();
|
|
95
|
-
await expect(page).toHaveURL(/\/
|
|
95
|
+
await expect(page).toHaveURL(/\/demo\/components/);
|
|
96
96
|
await expect(page.getByRole("heading", { name: "Native Components" })).toBeVisible();
|
|
97
97
|
|
|
98
98
|
const kept = await page.evaluate(() => (window as any).__spa);
|
|
@@ -112,7 +112,7 @@ Flow pages finish wiring once Alpine has walked the DOM and fired
|
|
|
112
112
|
`alpine:initialized`. A fast click can land before that and silently no-op (a flaky,
|
|
113
113
|
browser-dependent failure). Wait for readiness before interacting:
|
|
114
114
|
|
|
115
|
-
```typescript
|
|
115
|
+
```typescript fragment
|
|
116
116
|
// e2e/support/gotoReady.ts
|
|
117
117
|
import type { Page } from "@playwright/test";
|
|
118
118
|
|
|
@@ -128,7 +128,7 @@ export async function gotoReady(page: Page, url: string): Promise<void> {
|
|
|
128
128
|
}
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
-
Then `await gotoReady(page, "/
|
|
131
|
+
Then `await gotoReady(page, "/demo/counter")` instead of `page.goto(...)` for
|
|
132
132
|
interactive Flow pages.
|
|
133
133
|
|
|
134
134
|
> **Warning** — Clicking before `alpine:initialized` fires is the most common source
|
package/docs/testing/console.md
CHANGED
|
@@ -41,7 +41,7 @@ describe("cache:clear", () => {
|
|
|
41
41
|
Pass arguments and flags as a parameter map. Booleans become bare flags when `true`;
|
|
42
42
|
everything else becomes a `key=value` token, mirroring how the CLI parses argv:
|
|
43
43
|
|
|
44
|
-
```typescript
|
|
44
|
+
```typescript fragment
|
|
45
45
|
// in a test file
|
|
46
46
|
await Artisan.call("migrate", { "--fresh": true });
|
|
47
47
|
await Artisan.call("make:model", { name: "Post", "--migration": true });
|
|
@@ -55,7 +55,7 @@ Commands usually do work — touch the database, queue a job, write a file. Asse
|
|
|
55
55
|
_effect_, not just the output, using the [database](/docs/testing/database) and
|
|
56
56
|
[mocking](/docs/testing/mocking) helpers:
|
|
57
57
|
|
|
58
|
-
```typescript
|
|
58
|
+
```typescript fragment
|
|
59
59
|
// src/tests/PruneUsersTest.ts
|
|
60
60
|
import { Artisan } from "zerotal";
|
|
61
61
|
import { assertDatabaseCount, QueueFake } from "@zerotal/testing";
|
|
@@ -79,7 +79,7 @@ For a focused test of a single command's logic, instantiate it directly and inje
|
|
|
79
79
|
`BufferWriter` to capture output. The `_writer`, `args`, and `flags` fields are
|
|
80
80
|
internal — the runner normally sets them — so a unit test reaches in with a cast:
|
|
81
81
|
|
|
82
|
-
```typescript
|
|
82
|
+
```typescript fragment
|
|
83
83
|
// src/tests/GreetCommandTest.ts
|
|
84
84
|
import { expect } from "bun:test";
|
|
85
85
|
import { BufferWriter } from "zerotal";
|
package/docs/testing/database.md
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "@zerotal/testing";
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
> **Note** — These helpers are part of the [testing toolkit](/docs/testing
|
|
24
|
+
> **Note** — These helpers are part of the [testing toolkit](/docs/testing) and
|
|
25
25
|
> only run inside `bun test`. Install the package as a dev dependency if it isn't
|
|
26
26
|
> already: `bun add -d @zerotal/testing`.
|
|
27
27
|
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
A test needs tables before it needs rows. Build them by running the project's own
|
|
31
31
|
[migrations](/docs/migrations) rather than by writing the schema a second time:
|
|
32
32
|
|
|
33
|
-
```typescript
|
|
33
|
+
```typescript fragment
|
|
34
34
|
// tests/Feature/PostTest.ts
|
|
35
35
|
import { migrateDatabase } from "@zerotal/testing";
|
|
36
36
|
|
|
@@ -46,7 +46,7 @@ until something fails for a reason that has nothing to do with the change you
|
|
|
46
46
|
made. Running the migrations themselves means the schema under test is the schema
|
|
47
47
|
that ships.
|
|
48
48
|
|
|
49
|
-
```typescript
|
|
49
|
+
```typescript fragment
|
|
50
50
|
// signature
|
|
51
51
|
function migrateDatabase(options?: MigrateDatabaseOptions): Promise<string[]>;
|
|
52
52
|
```
|
|
@@ -61,7 +61,7 @@ It returns the names of the migrations it applied, and is idempotent — a secon
|
|
|
61
61
|
call applies nothing. `refreshDatabase({ migrate: true })` runs it for you, which
|
|
62
62
|
is usually what you want:
|
|
63
63
|
|
|
64
|
-
```typescript
|
|
64
|
+
```typescript fragment
|
|
65
65
|
// in a test file
|
|
66
66
|
describe("Post", () => {
|
|
67
67
|
refreshDatabase({ connection: db, migrate: true });
|
|
@@ -92,7 +92,7 @@ Call it once inside a `describe` block. Every `it()` in that block runs inside a
|
|
|
92
92
|
transaction that rolls back when the test ends, so each test starts from the same
|
|
93
93
|
clean baseline:
|
|
94
94
|
|
|
95
|
-
```typescript
|
|
95
|
+
```typescript fragment
|
|
96
96
|
// tests/Feature/UserTest.ts
|
|
97
97
|
import { describe, it } from "bun:test";
|
|
98
98
|
import { refreshDatabase, assertDatabaseHas, assertDatabaseMissing } from "@zerotal/testing";
|
|
@@ -115,7 +115,7 @@ describe("User", () => {
|
|
|
115
115
|
});
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
```typescript
|
|
118
|
+
```typescript fragment
|
|
119
119
|
// signature
|
|
120
120
|
function refreshDatabase(options?: RefreshDatabaseOptions): void;
|
|
121
121
|
```
|
|
@@ -137,7 +137,7 @@ fixtures), while each test's own writes happen inside the rolled-back transactio
|
|
|
137
137
|
|
|
138
138
|
When you want rollback for a single test rather than a whole block, wrap the test body:
|
|
139
139
|
|
|
140
|
-
```typescript
|
|
140
|
+
```typescript fragment
|
|
141
141
|
// in a test file
|
|
142
142
|
import { withDatabase } from "@zerotal/testing";
|
|
143
143
|
|
|
@@ -150,7 +150,7 @@ it(
|
|
|
150
150
|
); // ← transaction rolled back here
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
```typescript
|
|
153
|
+
```typescript fragment
|
|
154
154
|
// signature
|
|
155
155
|
function withDatabase(fn: () => Promise<void>): () => Promise<void>;
|
|
156
156
|
```
|
|
@@ -201,7 +201,7 @@ await assertMissingFile("local", "uploads/old.jpg");
|
|
|
201
201
|
Use [factories](/docs/orm/factories) to build the records a test acts on, and
|
|
202
202
|
[seeders](/docs/seeding) for shared fixtures:
|
|
203
203
|
|
|
204
|
-
```typescript
|
|
204
|
+
```typescript fragment
|
|
205
205
|
// in a test file
|
|
206
206
|
import { UserFactory } from "../database/factories/UserFactory.ts";
|
|
207
207
|
import { PostFactory } from "../database/factories/PostFactory.ts";
|
|
@@ -231,7 +231,7 @@ jobs — call `.dispatchEvents()` when a test needs the full lifecycle.
|
|
|
231
231
|
|
|
232
232
|
## Next steps
|
|
233
233
|
|
|
234
|
-
- [Testing overview](/docs/testing
|
|
234
|
+
- [Testing overview](/docs/testing) — the full toolkit and a first test.
|
|
235
235
|
- [Factories](/docs/orm/factories) — generating model records for tests.
|
|
236
236
|
- [Seeding](/docs/seeding) — reusable fixtures across dev and tests.
|
|
237
237
|
- [Database](/docs/database) — transactions and the raw `DB` layer being rolled back.
|
|
@@ -8,7 +8,7 @@ description: Drive a real page against a real server inside bun test, and assert
|
|
|
8
8
|
`FlowBrowser` runs a headless browser against your app inside `bun test`, so a test
|
|
9
9
|
can assert on the thing `FlowTest` cannot reach: the WebSocket bridge.
|
|
10
10
|
|
|
11
|
-
```ts
|
|
11
|
+
```ts fragment
|
|
12
12
|
import { FlowBrowser } from "@zerotal/testing/browser";
|
|
13
13
|
|
|
14
14
|
const browser = await FlowBrowser.serve(() => import("../bootstrap/app.ts").then((m) => m.default));
|
|
@@ -70,7 +70,7 @@ The two environments differ deliberately. **A browser suite that skips itself in
|
|
|
70
70
|
is worse than no suite**, because it reports green for exactly the failures it exists
|
|
71
71
|
to catch. So pair the skip with a guard that runs regardless:
|
|
72
72
|
|
|
73
|
-
```ts
|
|
73
|
+
```ts fragment
|
|
74
74
|
import { FlowBrowser, browserAvailability, browserRequired } from "@zerotal/testing/browser";
|
|
75
75
|
|
|
76
76
|
const availability = await browserAvailability();
|
|
@@ -94,7 +94,7 @@ that sets it does not have to hard-code the string.
|
|
|
94
94
|
|
|
95
95
|
## Serving the app
|
|
96
96
|
|
|
97
|
-
```ts
|
|
97
|
+
```ts fragment
|
|
98
98
|
const browser = await FlowBrowser.serve(bootstrap, options?);
|
|
99
99
|
```
|
|
100
100
|
|
|
@@ -112,7 +112,7 @@ through a second bootstrap that can drift. The server binds an OS-assigned port.
|
|
|
112
112
|
Call `await browser.stop()` in `afterAll`: it closes every page it opened, then stops
|
|
113
113
|
the server.
|
|
114
114
|
|
|
115
|
-
```ts
|
|
115
|
+
```ts fragment
|
|
116
116
|
let browser: FlowBrowser;
|
|
117
117
|
beforeAll(async () => {
|
|
118
118
|
browser = await FlowBrowser.serve(bootstrap);
|
|
@@ -168,7 +168,7 @@ suite gets deleted.
|
|
|
168
168
|
`page.transport()` reports what Chrome saw on the wire — which the page cannot lie
|
|
169
169
|
about. A client that degraded silently still shows zero upgraded sockets here.
|
|
170
170
|
|
|
171
|
-
```ts
|
|
171
|
+
```ts fragment
|
|
172
172
|
expect(page.socketUpgraded()).toBe(true);
|
|
173
173
|
expect(page.transport().statuses).toContain(101);
|
|
174
174
|
expect(await page.connection()).toBe("online");
|
|
@@ -192,7 +192,7 @@ It returns a `TransportReport`:
|
|
|
192
192
|
`frames` carries the payloads, which is how you assert on what actually crossed the
|
|
193
193
|
wire rather than on what the DOM ended up showing:
|
|
194
194
|
|
|
195
|
-
```ts
|
|
195
|
+
```ts fragment
|
|
196
196
|
const sent = page.transport().frames.filter((f) => f.direction === "sent");
|
|
197
197
|
expect(sent.some((f) => f.payload.includes(`"method":"save"`))).toBe(true);
|
|
198
198
|
```
|
package/docs/testing/http.md
CHANGED
|
@@ -28,7 +28,7 @@ import { createTestApp, type TestApp } from "@zerotal/testing";
|
|
|
28
28
|
|
|
29
29
|
Boot the app once per suite and close it when the suite finishes:
|
|
30
30
|
|
|
31
|
-
```typescript
|
|
31
|
+
```typescript fragment
|
|
32
32
|
// src/tests/PostTest.ts
|
|
33
33
|
import { describe, it, beforeAll, afterAll } from "bun:test";
|
|
34
34
|
import { createTestApp, type TestApp } from "@zerotal/testing";
|
|
@@ -45,7 +45,7 @@ afterAll(() => testApp.close());
|
|
|
45
45
|
|
|
46
46
|
`createTestApp()` boots the app and starts it on an OS-assigned port (port `0`):
|
|
47
47
|
|
|
48
|
-
```typescript
|
|
48
|
+
```typescript fragment
|
|
49
49
|
function createTestApp(
|
|
50
50
|
bootstrap: () => Application | Promise<Application>,
|
|
51
51
|
setup?: () => void,
|
|
@@ -82,7 +82,7 @@ call `closeSharedTestApps()`.
|
|
|
82
82
|
|
|
83
83
|
## Sending requests
|
|
84
84
|
|
|
85
|
-
```typescript
|
|
85
|
+
```typescript fragment
|
|
86
86
|
// in a test
|
|
87
87
|
await testApp.get("/path");
|
|
88
88
|
await testApp.post("/path", body); // body serialized as JSON
|
|
@@ -104,7 +104,7 @@ A route meant for a browser should be tested the way a browser reaches it. A JSO
|
|
|
104
104
|
redirect-back-with-errors branch of validation, the CSRF check, and any middleware
|
|
105
105
|
that reads `application/x-www-form-urlencoded`.
|
|
106
106
|
|
|
107
|
-
```typescript
|
|
107
|
+
```typescript fragment
|
|
108
108
|
// in a test
|
|
109
109
|
const res = await testApp.postForm("/posts", { title: "Hello", published: true });
|
|
110
110
|
|
|
@@ -120,7 +120,7 @@ strings `"null"` and `"undefined"`.
|
|
|
120
120
|
`multipart()` is the only way to exercise a route that reads an uploaded file.
|
|
121
121
|
Build the attachments with `fakeFile`:
|
|
122
122
|
|
|
123
|
-
```typescript
|
|
123
|
+
```typescript fragment
|
|
124
124
|
// in a test
|
|
125
125
|
import { fakeFile } from "@zerotal/testing";
|
|
126
126
|
|
|
@@ -154,7 +154,7 @@ without a request, build one with `UploadedFile.fake('a.png', { type, size })`.
|
|
|
154
154
|
|
|
155
155
|
### Authentication
|
|
156
156
|
|
|
157
|
-
```typescript
|
|
157
|
+
```typescript fragment
|
|
158
158
|
// in a test
|
|
159
159
|
|
|
160
160
|
// Forge a signed session cookie so the request is treated as authenticated
|
|
@@ -173,7 +173,7 @@ any `user_id` already set by `actingAs()`.
|
|
|
173
173
|
|
|
174
174
|
### Headers and redirects
|
|
175
175
|
|
|
176
|
-
```typescript
|
|
176
|
+
```typescript fragment
|
|
177
177
|
// in a test
|
|
178
178
|
testApp.withHeaders({ "X-App-Version": "2" });
|
|
179
179
|
testApp.withCookie("theme", "dark");
|
|
@@ -203,7 +203,7 @@ An exception inside a route is converted to a response before the test sees it,
|
|
|
203
203
|
so the assertion reports `500` and the body is an error page. `withoutExceptionHandling()`
|
|
204
204
|
hands you the original instead:
|
|
205
205
|
|
|
206
|
-
```typescript
|
|
206
|
+
```typescript fragment
|
|
207
207
|
// in a test
|
|
208
208
|
const res = await testApp.withoutExceptionHandling().get("/checkout");
|
|
209
209
|
|
|
@@ -226,7 +226,7 @@ into the test output. Call `withExceptionHandling()` to restore the default.
|
|
|
226
226
|
Every request returns a `TestResponse`. Assertions are chainable and throw
|
|
227
227
|
descriptive errors on failure.
|
|
228
228
|
|
|
229
|
-
```typescript
|
|
229
|
+
```typescript fragment
|
|
230
230
|
// in a test
|
|
231
231
|
|
|
232
232
|
// Status
|
|
@@ -300,7 +300,7 @@ A failed validation reaches the client two different ways: an API client gets
|
|
|
300
300
|
flashed to the session. `assertInvalid` reads both, so the assertion is the same
|
|
301
301
|
either way:
|
|
302
302
|
|
|
303
|
-
```typescript
|
|
303
|
+
```typescript fragment
|
|
304
304
|
// in a test
|
|
305
305
|
|
|
306
306
|
// API client
|
|
@@ -331,7 +331,7 @@ reporting the key as absent.
|
|
|
331
331
|
|
|
332
332
|
Reading the body directly:
|
|
333
333
|
|
|
334
|
-
```typescript
|
|
334
|
+
```typescript fragment
|
|
335
335
|
// in a test
|
|
336
336
|
const data = res.json<{ id: number }>();
|
|
337
337
|
const html = res.text();
|
|
@@ -343,7 +343,7 @@ const ok = res.ok; // boolean
|
|
|
343
343
|
|
|
344
344
|
Skip HTTP entirely for fast, focused controller tests by faking the context:
|
|
345
345
|
|
|
346
|
-
```typescript
|
|
346
|
+
```typescript fragment
|
|
347
347
|
// src/tests/PostControllerTest.ts
|
|
348
348
|
import { HttpContext } from "zerotal";
|
|
349
349
|
|
package/docs/testing/index.md
CHANGED
|
@@ -64,7 +64,7 @@ add it here too — that one edit keeps every test in the suite honest.
|
|
|
64
64
|
|
|
65
65
|
From then on a test is two lines of setup:
|
|
66
66
|
|
|
67
|
-
```typescript
|
|
67
|
+
```typescript fragment
|
|
68
68
|
// tests/http/posts.test.ts
|
|
69
69
|
import { test } from "bun:test";
|
|
70
70
|
import { createApp } from "../helpers.ts";
|
|
@@ -149,7 +149,7 @@ building its own.
|
|
|
149
149
|
`createTestApp()` boots your application, starts it on a random port, and returns a
|
|
150
150
|
`TestApp` client. Pair it with a [factory](/docs/orm/factories) to arrange data:
|
|
151
151
|
|
|
152
|
-
```typescript
|
|
152
|
+
```typescript fragment
|
|
153
153
|
// tests/Feature/PostTest.ts
|
|
154
154
|
import { describe, it, beforeAll, afterAll } from "bun:test";
|
|
155
155
|
import { createTestApp, migrateDatabase, type TestApp, assertDatabaseHas } from "@zerotal/testing";
|
|
@@ -197,7 +197,7 @@ See [HTTP Tests](/docs/testing/http) for the full `TestApp` and `TestResponse` A
|
|
|
197
197
|
|
|
198
198
|
## Resetting framework state
|
|
199
199
|
|
|
200
|
-
```typescript
|
|
200
|
+
```typescript fragment
|
|
201
201
|
// tests/Feature/SomeTest.ts
|
|
202
202
|
import { resetTestState } from "@zerotal/testing";
|
|
203
203
|
|
|
@@ -209,6 +209,32 @@ observers, global scopes, and state-machine callbacks, plus framework event
|
|
|
209
209
|
subscriptions. `createTestApp()` and `testApp.close()` call it for you, so suites
|
|
210
210
|
using those helpers don't need the explicit `afterEach`.
|
|
211
211
|
|
|
212
|
+
## Running the suite from a script
|
|
213
|
+
|
|
214
|
+
A script that gates on the tests has to read the tests' exit status, and a pipe hides it:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
bun test 2>&1 | tail -3 # the status is tail's. Always 0, however the suite went.
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The suite is verbose enough that piping it somewhere is the natural thing to write, which
|
|
221
|
+
is what makes this worth saying: a deploy script written that way prints `1 fail` and
|
|
222
|
+
carries straight on to upload and restart. Nothing is wrong with the output — it is the
|
|
223
|
+
`$?` behind it that belongs to the last command in the pipe.
|
|
224
|
+
|
|
225
|
+
Either turn the pipe honest, or do not pipe:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
set -o pipefail # bash/zsh: the pipeline fails if any stage does
|
|
229
|
+
bun test 2>&1 | tail -3
|
|
230
|
+
|
|
231
|
+
# or keep the status and the output separately
|
|
232
|
+
bun test > test.log 2>&1 || { tail -20 test.log; exit 1; }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`set -e` alone does not cover it — the pipeline succeeded, as far as the shell is
|
|
236
|
+
concerned.
|
|
237
|
+
|
|
212
238
|
## References
|
|
213
239
|
|
|
214
240
|
The most-used members exported from `@zerotal/testing`. Each area's page documents
|
package/docs/testing/mocking.md
CHANGED
|
@@ -46,7 +46,7 @@ Everything except `Broadcast.fake()` and `Social.fake()` is re-exported from
|
|
|
46
46
|
`EventFake.install()` replaces the `events` binding, so emitted events are
|
|
47
47
|
recorded and their listeners never run.
|
|
48
48
|
|
|
49
|
-
```typescript
|
|
49
|
+
```typescript fragment
|
|
50
50
|
// src/tests/PostPublishTest.ts
|
|
51
51
|
import { EventFake } from "@zerotal/testing";
|
|
52
52
|
|
|
@@ -80,7 +80,7 @@ search indexing, cache invalidation.
|
|
|
80
80
|
`QueueFake.install()` replaces the `queue` binding so dispatched jobs are
|
|
81
81
|
captured instead of persisted or executed.
|
|
82
82
|
|
|
83
|
-
```typescript
|
|
83
|
+
```typescript fragment
|
|
84
84
|
// src/tests/PostPublishTest.ts
|
|
85
85
|
import { QueueFake } from "@zerotal/testing"; // re-exported from @zerotal/queue
|
|
86
86
|
|
|
@@ -111,7 +111,7 @@ it("queues a job when a post is published", async () => {
|
|
|
111
111
|
`Notify.send()` and `Notify.queue()` are captured as "sent". The `mail` channel
|
|
112
112
|
runs through `Notify`, so faking notifications also covers email side effects.
|
|
113
113
|
|
|
114
|
-
```typescript
|
|
114
|
+
```typescript fragment
|
|
115
115
|
// src/tests/InvoiceTest.ts
|
|
116
116
|
import { NotificationFake } from "@zerotal/testing"; // re-exported from @zerotal/notifications
|
|
117
117
|
import { Notify } from "@zerotal/notifications";
|
|
@@ -142,7 +142,7 @@ The `Broadcast` facade has its own recorder. Call `Broadcast.fake()` to install
|
|
|
142
142
|
it and `Broadcast.resetFake()` to restore — the returned fake exposes the
|
|
143
143
|
assertions:
|
|
144
144
|
|
|
145
|
-
```typescript
|
|
145
|
+
```typescript fragment
|
|
146
146
|
// src/tests/PostBroadcastTest.ts
|
|
147
147
|
import { Broadcast } from "@zerotal/broadcasting";
|
|
148
148
|
|
|
@@ -168,7 +168,7 @@ touches the filesystem, so each test starts empty by construction rather than by
|
|
|
168
168
|
remembering to clean up — and a suite that forgets to clean up passes the second
|
|
169
169
|
time for the wrong reason.
|
|
170
170
|
|
|
171
|
-
```typescript
|
|
171
|
+
```typescript fragment
|
|
172
172
|
// src/tests/AvatarTest.ts
|
|
173
173
|
import { Storage } from "zerotal/storage";
|
|
174
174
|
import { fakeFile } from "@zerotal/testing";
|
|
@@ -201,7 +201,7 @@ it directly.
|
|
|
201
201
|
client, so a test that exercises a service integration makes no real network
|
|
202
202
|
call — and does not fail when that service is down or rate-limits you.
|
|
203
203
|
|
|
204
|
-
```typescript
|
|
204
|
+
```typescript fragment
|
|
205
205
|
// src/tests/PaymentTest.ts
|
|
206
206
|
import { Http } from "@zerotal/testing";
|
|
207
207
|
|
|
@@ -231,9 +231,9 @@ Behaviour that depends on the passage of time — a token that expires in seven
|
|
|
231
231
|
days, a reminder that only fires after 24 hours — has no way to be tested except
|
|
232
232
|
by waiting. Freeze the clock instead:
|
|
233
233
|
|
|
234
|
-
```typescript
|
|
234
|
+
```typescript fragment
|
|
235
235
|
// src/tests/InvitationTest.ts
|
|
236
|
-
import { Carbon } from "zerotal";
|
|
236
|
+
import { Carbon } from "zerotal/carbon";
|
|
237
237
|
|
|
238
238
|
afterEach(() => Carbon.release());
|
|
239
239
|
|
package/docs/upgrade.md
CHANGED
|
@@ -18,7 +18,7 @@ version line:
|
|
|
18
18
|
- **Major** (`X.y.z`) — breaking changes; read the version's section in the
|
|
19
19
|
[Release Notes](/docs/changelog) before upgrading.
|
|
20
20
|
|
|
21
|
-
> **Warning** — while the 1.x line is young, a breaking change may also land in a minor or a patch. It is always labelled **BREAKING** in the [Release Notes](/docs/changelog) with migration steps
|
|
21
|
+
> **Warning** — while the 1.x line is young, a breaking change may also land in a minor or a patch. It is always labelled **BREAKING** in the [Release Notes](/docs/changelog) with migration steps. Read the notes for every version you cross, not only the majors. See [Releases and versioning](/docs/support-policy#releases-and-versioning) for which ones have shipped and when this carve-out ends.
|
|
22
22
|
|
|
23
23
|
> **Warning** — always upgrade the `@zerotal/*` packages together. Mixing versions across core, ORM, and feature packages leads to type and runtime mismatches.
|
|
24
24
|
|