@orieljs/framework 0.1.0-alpha.1 → 0.1.0-alpha.10

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 (87) hide show
  1. package/NOTICE +7 -0
  2. package/README.md +10 -1
  3. package/TRADEMARKS.md +33 -0
  4. package/dist/chunk-7ZQZYDOC.js +3253 -0
  5. package/dist/chunk-7ZQZYDOC.js.map +1 -0
  6. package/dist/{chunk-K6MAA3RM.js → chunk-GVNLZZTN.js} +7 -1
  7. package/dist/chunk-GVNLZZTN.js.map +1 -0
  8. package/dist/chunk-OXAATQA6.js +8433 -0
  9. package/dist/chunk-OXAATQA6.js.map +1 -0
  10. package/dist/{chunk-WI7E7Q46.js → chunk-VOYW3R5J.js} +8 -4
  11. package/dist/chunk-VOYW3R5J.js.map +1 -0
  12. package/dist/contracts.d.ts +841 -168
  13. package/dist/contracts.js +7 -1
  14. package/dist/index.d-WQpfdIbG.d.ts +630 -0
  15. package/dist/index.d.ts +2916 -5178
  16. package/dist/index.js +7357 -2609
  17. package/dist/index.js.map +1 -1
  18. package/dist/{jsx-runtime-B8J6xq-7.d.ts → jsx-runtime-6um4Mwfb.d.ts} +99 -149
  19. package/dist/jsx-runtime.d.ts +3 -1
  20. package/dist/jsx-runtime.js +3 -3
  21. package/dist/testing-CKwfWJGn.d.ts +1586 -0
  22. package/dist/testing.d.ts +3 -1408
  23. package/dist/testing.js +29 -5
  24. package/docs/authentication.md +487 -0
  25. package/docs/authorization.md +435 -0
  26. package/docs/cache.md +647 -0
  27. package/docs/collections.md +2270 -0
  28. package/docs/configuration.md +334 -0
  29. package/docs/console.md +652 -0
  30. package/docs/container.md +430 -0
  31. package/docs/controllers.md +406 -0
  32. package/docs/csrf.md +126 -0
  33. package/docs/database-testing.md +144 -0
  34. package/docs/database.md +236 -0
  35. package/docs/deployment.md +276 -0
  36. package/docs/eloquent-collections.md +218 -0
  37. package/docs/eloquent-factories.md +255 -0
  38. package/docs/eloquent-mutators.md +254 -0
  39. package/docs/eloquent-observers.md +145 -0
  40. package/docs/eloquent-relationships.md +557 -0
  41. package/docs/eloquent.md +494 -0
  42. package/docs/encryption.md +104 -0
  43. package/docs/errors.md +318 -0
  44. package/docs/events.md +336 -0
  45. package/docs/facades.md +278 -0
  46. package/docs/hashing.md +118 -0
  47. package/docs/helpers.md +1281 -0
  48. package/docs/http-tests.md +386 -0
  49. package/docs/inertia.md +485 -0
  50. package/docs/installation.md +209 -0
  51. package/docs/lifecycle.md +234 -0
  52. package/docs/live-tests.md +226 -0
  53. package/docs/live.md +910 -0
  54. package/docs/logging.md +255 -0
  55. package/docs/mail.md +619 -0
  56. package/docs/middleware.md +384 -0
  57. package/docs/migrations.md +416 -0
  58. package/docs/mocking.md +264 -0
  59. package/docs/navigation.json +273 -0
  60. package/docs/notifications.md +225 -0
  61. package/docs/pagination.md +247 -0
  62. package/docs/passwords.md +207 -0
  63. package/docs/providers.md +187 -0
  64. package/docs/queries.md +600 -0
  65. package/docs/queues.md +330 -0
  66. package/docs/requests.md +533 -0
  67. package/docs/responses.md +444 -0
  68. package/docs/routing.md +738 -0
  69. package/docs/scheduling.md +277 -0
  70. package/docs/seeding.md +122 -0
  71. package/docs/session.md +409 -0
  72. package/docs/starter-kits.md +396 -0
  73. package/docs/storage.md +192 -0
  74. package/docs/structure.md +264 -0
  75. package/docs/testing.md +165 -0
  76. package/docs/time-travel.md +138 -0
  77. package/docs/validation.md +1792 -0
  78. package/docs/verification.md +152 -0
  79. package/docs/views.md +566 -0
  80. package/docs/vite.md +322 -0
  81. package/package.json +35 -30
  82. package/dist/chunk-AI2RFMBT.js +0 -745
  83. package/dist/chunk-AI2RFMBT.js.map +0 -1
  84. package/dist/chunk-BZPCRKFQ.js +0 -2941
  85. package/dist/chunk-BZPCRKFQ.js.map +0 -1
  86. package/dist/chunk-K6MAA3RM.js.map +0 -1
  87. package/dist/chunk-WI7E7Q46.js.map +0 -1
@@ -0,0 +1,416 @@
1
+ # Database: migrations
2
+
3
+ Migrations are your database's version control: each one changes the schema, in code, so every copy of your app,
4
+ your colleagues' and your servers', builds the same tables.
5
+
6
+ ## Introduction
7
+
8
+ A migration is a class with an `up()` method, which changes the database, and a `down()` method, which undoes it.
9
+ They describe tables with the `Schema` facade, which writes the SQL for SQLite, PostgreSQL and MySQL. Your migrations
10
+ are in `database/migrations`, and a new app has five: its users, sessions and password reset tokens, its cache, its
11
+ jobs, its personal access tokens and its notifications.
12
+
13
+ ## Generating migrations
14
+
15
+ `make:migration` writes a migration in `database/migrations`. The name of the file starts with the date and time, so
16
+ the migrations run in the order you write them:
17
+
18
+ ```sh
19
+ node oriel make:migration create_flights_table
20
+ ```
21
+
22
+ A name such as `create_flights_table` writes a migration that creates the `flights` table, and one such as
23
+ `add_votes_to_users_table` a migration that changes `users`. `--create=flights` and `--table=users` name the table
24
+ yourself, and `--path` writes the file in another folder.
25
+
26
+ ## Migration structure
27
+
28
+ A migration's file default-exports the class. `up()` adds tables, columns or indexes; `down()` reverses what `up()`
29
+ did:
30
+
31
+ ```ts title="database/migrations/2026_10_20_000000_create_flights_table.ts"
32
+ import { Migration, Schema } from '@orieljs/framework'
33
+
34
+ export default class extends Migration {
35
+ /** Run the migrations. */
36
+ async up() {
37
+ await Schema.create('flights', (table) => {
38
+ table.id()
39
+ table.string('name')
40
+ table.string('airline')
41
+ table.timestamps()
42
+ })
43
+ }
44
+
45
+ /** Reverse the migrations. */
46
+ async down() {
47
+ await Schema.dropIfExists('flights')
48
+ }
49
+ }
50
+ ```
51
+
52
+ #### Setting the migration connection
53
+
54
+ A migration runs on the default connection, unless its `connection` names another:
55
+
56
+ ```ts
57
+ export default class extends Migration {
58
+ override connection = 'pgsql'
59
+
60
+ async up() {
61
+ // ...
62
+ }
63
+
64
+ async down() {
65
+ // ...
66
+ }
67
+ }
68
+ ```
69
+
70
+ #### Migrations in a transaction
71
+
72
+ On PostgreSQL and SQLite, each migration runs in a transaction, so one that fails halfway leaves nothing behind.
73
+ MySQL can't roll back schema changes, so there a failed migration may leave part of its work. Set
74
+ `withinTransaction = false` on a migration that mustn't run in a transaction.
75
+
76
+ ## Running migrations
77
+
78
+ `migrate` runs the migrations that haven't run yet:
79
+
80
+ ```sh
81
+ node oriel migrate
82
+ ```
83
+
84
+ `migrate:status` shows which have run. `migrate` records each one in the `migrations` table, which it creates the first
85
+ time, in a numbered batch: the migrations of one `migrate` are a batch, or one batch each with `--step`.
86
+
87
+ When it finishes, outside production and in an app with a `types` folder, `migrate` writes `types/models.d.ts` again, so
88
+ your models' types follow your tables (see the `model:types` command).
89
+
90
+ #### Forcing migrations to run in production
91
+
92
+ In production, `migrate` and the other commands that change your schema stop, unless you pass `--force`:
93
+
94
+ ```sh
95
+ node oriel migrate --force
96
+ ```
97
+
98
+ ### Rolling back migrations
99
+
100
+ `migrate:rollback` undoes the last batch, running each migration's `down()`. `--step` undoes that many migrations
101
+ instead, and `--batch` a batch by its number:
102
+
103
+ ```sh
104
+ node oriel migrate:rollback
105
+
106
+ node oriel migrate:rollback --step=5
107
+
108
+ node oriel migrate:rollback --batch=3
109
+ ```
110
+
111
+ `migrate:reset` undoes every migration.
112
+
113
+ #### Roll back and migrate using a single command
114
+
115
+ `migrate:refresh` undoes every migration, then runs them all again; `--step` undoes and runs again only that many.
116
+ `--seed` runs your seeders after it:
117
+
118
+ ```sh
119
+ node oriel migrate:refresh
120
+
121
+ node oriel migrate:refresh --seed
122
+ ```
123
+
124
+ #### Drop all tables and migrate
125
+
126
+ `migrate:fresh` drops every table, without running any `down()`, then runs every migration:
127
+
128
+ ```sh
129
+ node oriel migrate:fresh
130
+
131
+ node oriel migrate:fresh --seed
132
+ ```
133
+
134
+ > [!WARNING]
135
+ > `migrate:fresh` drops every table of the connection, those your migrations didn't make included.
136
+
137
+ Each command takes `--database` to run on another connection, and `--path` to run the migrations of another folder.
138
+
139
+ ## Tables
140
+
141
+ ### Creating tables
142
+
143
+ `Schema.create()` takes the table's name, and a function that defines its columns:
144
+
145
+ ```ts
146
+ await Schema.create('users', (table) => {
147
+ table.id()
148
+ table.string('name')
149
+ table.string('email')
150
+ table.timestamps()
151
+ })
152
+ ```
153
+
154
+ #### Checking for table / column existence
155
+
156
+ ```ts
157
+ if (await Schema.hasTable('users')) {
158
+ // The "users" table exists...
159
+ }
160
+
161
+ if (await Schema.hasColumn('users', 'email')) {
162
+ // The "users" table exists and has an "email" column...
163
+ }
164
+ ```
165
+
166
+ `hasColumns()` checks a list of columns, and `hasIndex()` an index.
167
+
168
+ #### Database connection
169
+
170
+ `Schema.connection()` works on another connection:
171
+
172
+ ```ts
173
+ await Schema.connection('sqlite').create('users', (table) => {
174
+ table.id()
175
+ })
176
+ ```
177
+
178
+ ### Updating tables
179
+
180
+ `Schema.table()` changes a table that exists:
181
+
182
+ ```ts
183
+ await Schema.table('users', (table) => {
184
+ table.integer('votes')
185
+ })
186
+ ```
187
+
188
+ ### Renaming / dropping tables
189
+
190
+ ```ts
191
+ await Schema.rename('posts', 'articles')
192
+
193
+ await Schema.drop('users')
194
+
195
+ await Schema.dropIfExists('users')
196
+ ```
197
+
198
+ ## Columns
199
+
200
+ ### Creating columns
201
+
202
+ Inside `Schema.table()`, each column method adds a column:
203
+
204
+ ```ts
205
+ await Schema.table('users', (table) => {
206
+ table.integer('votes')
207
+ })
208
+ ```
209
+
210
+ ### Available column types
211
+
212
+ | Method | Column |
213
+ | --- | --- |
214
+ | `id()` | An auto-incrementing `bigint` primary key, `id` |
215
+ | `increments('id')`, `bigIncrements('id')` | An auto-incrementing primary key |
216
+ | `integer()`, `bigInteger()`, `mediumInteger()`, `smallInteger()`, `tinyInteger()` | Integers |
217
+ | `unsignedInteger()`, `unsignedBigInteger()`, `unsignedSmallInteger()`, `unsignedTinyInteger()` | Unsigned integers |
218
+ | `foreignId('user_id')` | An unsigned `bigint` for a foreign key (see [Foreign key constraints](#foreign-key-constraints)) |
219
+ | `decimal('amount', 8, 2)` | A fixed-point number, with its precision and scale |
220
+ | `float('amount', 53)`, `double('amount')` | Floating-point numbers |
221
+ | `boolean('confirmed')` | A boolean |
222
+ | `string('name', 100)` | A `varchar`, 255 characters unless you give a length |
223
+ | `char('code', 4)` | A fixed-length string |
224
+ | `text()`, `mediumText()`, `longText()` | Text |
225
+ | `json()`, `jsonb()` | JSON (`jsonb` on PostgreSQL) |
226
+ | `uuid('id')`, `ulid('id')` | A UUID or a ULID |
227
+ | `binary('data')` | Binary data |
228
+ | `enum('difficulty', ['easy', 'hard'])` | One of a list of values |
229
+ | `date()`, `dateTime()`, `time()`, `timestamp()` | Dates and times, with an optional precision |
230
+ | `timestamps()` | Nullable `created_at` and `updated_at` timestamps |
231
+ | `nullableTimestamps()` | The same |
232
+ | `softDeletes()` | A nullable `deleted_at` timestamp, for soft deletes |
233
+ | `rememberToken()` | A nullable `remember_token`, for "Remember me" |
234
+ | `morphs('taggable')`, `nullableMorphs('taggable')` | `taggable_id` and `taggable_type`, for a polymorphic relation |
235
+
236
+ SQLite stores every integer type as `integer`, and JSON as text.
237
+
238
+ > [!NOTE]
239
+ > Laravel's `foreignIdFor()`, `foreignUuid()`, `uuidMorphs()`, the time zone columns (`timestampTz()` and the like),
240
+ > `year()`, `set()`, `ipAddress()`, `macAddress()`, generated columns (`virtualAs()`) and a column's own charset or
241
+ > collation aren't in Oriel. `timestamps()` returns nothing, so it can't take modifiers.
242
+
243
+ ### Column modifiers
244
+
245
+ Modifiers follow the column:
246
+
247
+ ```ts
248
+ await Schema.table('users', (table) => {
249
+ table.string('email').nullable()
250
+ })
251
+ ```
252
+
253
+ | Modifier | Description |
254
+ | --- | --- |
255
+ | `nullable()` | The column takes `NULL` |
256
+ | `default(value)` | The column's default value; `DB.raw()` writes an expression |
257
+ | `unsigned()` | An unsigned integer (MySQL) |
258
+ | `autoIncrement()` | An auto-incrementing integer, the primary key |
259
+ | `primary()`, `unique()`, `index()` | An index on the column (see [Indexes](#indexes)) |
260
+ | `useCurrent()` | A timestamp column defaults to the current time |
261
+ | `useCurrentOnUpdate()` | A timestamp column takes the current time when the row changes (MySQL) |
262
+ | `comment('...')` | A comment on the column (MySQL and PostgreSQL) |
263
+ | `after('column')`, `first()` | Where the column goes (MySQL) |
264
+
265
+ #### Default expressions
266
+
267
+ A default given with `DB.raw()` is written as SQL, so it can call a function:
268
+
269
+ ```ts
270
+ await Schema.create('flights', (table) => {
271
+ table.id()
272
+ table.json('movies').default(DB.raw('(JSON_ARRAY())'))
273
+ table.timestamp('created_at').default(DB.raw('CURRENT_TIMESTAMP'))
274
+ })
275
+ ```
276
+
277
+ ### Modifying columns
278
+
279
+ `change()` gives an existing column a new definition. The column becomes exactly what you write, so repeat every
280
+ modifier you keep:
281
+
282
+ ```ts
283
+ await Schema.table('users', (table) => {
284
+ table.string('name', 50).nullable().change()
285
+ })
286
+ ```
287
+
288
+ Here `name` becomes a nullable `varchar(50)`; without `nullable()`, it would become `NOT NULL`, which fails while a row
289
+ holds `NULL`.
290
+
291
+ In one `Schema.table()`, changes run after the columns you add and before everything else, such as renames, indexes
292
+ and drops: to change a column you rename, rename it in a `Schema.table()` of its own first.
293
+
294
+ ### Renaming columns
295
+
296
+ ```ts
297
+ await Schema.table('users', (table) => {
298
+ table.renameColumn('from', 'to')
299
+ })
300
+ ```
301
+
302
+ ### Dropping columns
303
+
304
+ `dropColumn()` takes a column, or a list of them:
305
+
306
+ ```ts
307
+ await Schema.table('users', (table) => {
308
+ table.dropColumn(['votes', 'avatar', 'location'])
309
+ })
310
+ ```
311
+
312
+ `dropTimestamps()`, `dropSoftDeletes()`, `dropRememberToken()` and `dropMorphs('taggable')` drop the columns their
313
+ creating method added.
314
+
315
+ ## Indexes
316
+
317
+ ### Creating indexes
318
+
319
+ `unique()` after a column makes it unique, and `index()` indexes it:
320
+
321
+ ```ts
322
+ await Schema.table('users', (table) => {
323
+ table.string('email').unique()
324
+ })
325
+ ```
326
+
327
+ On the table, they take one column or a list, for a compound index, and a name:
328
+
329
+ ```ts
330
+ await Schema.table('users', (table) => {
331
+ table.index(['account_id', 'created_at'])
332
+ table.unique('email', 'unique_email')
333
+ table.primary(['id', 'parent_id'])
334
+ })
335
+ ```
336
+
337
+ An index without a name is named after the table, the columns and its kind: `users_email_unique`.
338
+
339
+ ### Dropping indexes
340
+
341
+ `dropUnique()` and `dropIndex()` take the index's name, or its columns, from which the name is made; `dropPrimary()`
342
+ takes the name, `{table}_pkey` unless you give one:
343
+
344
+ ```ts
345
+ await Schema.table('users', (table) => {
346
+ table.dropUnique('users_email_unique')
347
+ table.dropIndex(['account_id', 'created_at'])
348
+ })
349
+ ```
350
+
351
+ ### Foreign key constraints
352
+
353
+ `foreign()` adds a foreign key: the column, the column it refers to, and the table:
354
+
355
+ ```ts
356
+ await Schema.table('posts', (table) => {
357
+ table.unsignedBigInteger('user_id')
358
+
359
+ table.foreign('user_id').references('id').on('users')
360
+ })
361
+ ```
362
+
363
+ `foreignId()` with `constrained()` does both in one line: `constrained()` guesses the table from the column's name,
364
+ `users` for `user_id`, or takes it:
365
+
366
+ ```ts
367
+ await Schema.table('posts', (table) => {
368
+ table.foreignId('user_id').constrained()
369
+
370
+ table.foreignId('author_id').constrained('users')
371
+ })
372
+ ```
373
+
374
+ The actions on delete and update follow:
375
+
376
+ ```ts
377
+ await Schema.table('posts', (table) => {
378
+ table.foreignId('user_id').constrained().cascadeOnDelete()
379
+ })
380
+ ```
381
+
382
+ | Method | Action |
383
+ | --- | --- |
384
+ | `cascadeOnDelete()`, `cascadeOnUpdate()` | Delete or update the child rows too |
385
+ | `restrictOnDelete()`, `restrictOnUpdate()` | Refuse the delete or the update |
386
+ | `nullOnDelete()`, `nullOnUpdate()` | Set the child's column to `NULL` |
387
+ | `noActionOnDelete()`, `noActionOnUpdate()` | Leave the check to the end of the statement |
388
+
389
+ `dropForeign()` drops a foreign key, given its columns: `table.dropForeign(['user_id'])`.
390
+ `dropConstrainedForeignId('user_id')` drops the key and the column.
391
+
392
+ #### Toggling foreign key constraints
393
+
394
+ ```ts
395
+ await Schema.enableForeignKeyConstraints()
396
+
397
+ await Schema.disableForeignKeyConstraints()
398
+
399
+ await Schema.withoutForeignKeyConstraints(async () => {
400
+ // Constraints disabled within this closure...
401
+ })
402
+ ```
403
+
404
+ ## SQLite and schema changes
405
+
406
+ SQLite can't change a column, or add or drop a primary or foreign key, on a table that exists. Oriel does these as
407
+ Laravel does: it builds the table again with the new definition, copies the rows over, and drops the old one. You
408
+ write the migration as for any database, with `Schema.table()`.
409
+
410
+ Each migration on SQLite runs in a transaction with foreign keys turned off, as SQLite asks for schema changes, and
411
+ checks every foreign key before it commits: a migration that leaves a row pointing at a missing parent fails, and
412
+ nothing of it is kept. With foreign keys off, `ON DELETE` and `ON UPDATE` actions don't run inside the migration, so
413
+ change the child rows yourself first, or set `withinTransaction = false`.
414
+
415
+ > [!NOTE]
416
+ > Laravel's `schema:dump`, `db:wipe`, `migrate --pretend`, full text indexes and `renameIndex()` aren't in Oriel.
@@ -0,0 +1,264 @@
1
+ # Fakes
2
+
3
+ A fake stands in for a service in your tests: it records what your code asks of it, does nothing else, and lets you
4
+ check what was asked. Oriel has fakes for the bus, events, mail, notifications, queues and storage.
5
+
6
+ ## Introduction
7
+
8
+ `Mail.fake()` and the other `fake()` methods put a fake in place of the service, for the rest of the test, and return
9
+ it. Check your code's work on the fake they return:
10
+
11
+ ```ts title="tests/Feature/OrderShipment.test.ts"
12
+ import { Mail } from '@orieljs/framework'
13
+ import { test } from 'vitest'
14
+ import { OrderShipped } from '../../app/Mail/OrderShipped'
15
+ import { TestCase } from '../TestCase'
16
+
17
+ const t = TestCase.boot()
18
+
19
+ test('orders can be shipped', async () => {
20
+ const mail = Mail.fake()
21
+
22
+ await t.post('/orders/1/shipment')
23
+
24
+ mail.assertSent(OrderShipped)
25
+ })
26
+ ```
27
+
28
+ Each test has a new application, so a fake ends with its test.
29
+
30
+ > [!NOTE]
31
+ > Laravel's facades also have Mockery's `shouldReceive()`, `spy()` and `partialMock()`, and its tests have
32
+ > `$this->mock()`. Oriel has none of them: for a service without a fake, put a stand-in of your own in its place with
33
+ > its facade's `swap()`, or `t.app.instance(Service, standIn)`. Vitest's `vi.fn()` and `vi.spyOn()` work on any
34
+ > object. [Facades](facades.md#facades-vs-dependency-injection) shows `swap()`.
35
+
36
+ ## Bus fake
37
+
38
+ `Bus.fake()` records the jobs your code dispatches, and runs none of them:
39
+
40
+ ```ts title="tests/Feature/Podcasts.test.ts"
41
+ import { Bus } from '@orieljs/framework'
42
+ import { RefreshDatabase } from '@orieljs/framework/testing'
43
+ import { test } from 'vitest'
44
+ import { OptimizePodcast } from '../../app/Jobs/OptimizePodcast'
45
+ import { ProcessPodcast } from '../../app/Jobs/ProcessPodcast'
46
+ import { TestCase } from '../TestCase'
47
+
48
+ const t = TestCase.boot({ uses: [RefreshDatabase] })
49
+
50
+ test('podcasts can be published', async () => {
51
+ const bus = Bus.fake()
52
+
53
+ await t.post('/podcasts', { title: 'Episode 1' })
54
+
55
+ bus.assertDispatched(ProcessPodcast, (job) => job.podcast.title === 'Episode 1')
56
+ bus.assertDispatched(ProcessPodcast, 1)
57
+ bus.assertNotDispatched(OptimizePodcast)
58
+ })
59
+ ```
60
+
61
+ The second argument is a function that receives each job and returns `true` for the one you expect, or a number of
62
+ dispatches. Give `fake()` a list of jobs to fake only those, and the others run as usual: `Bus.fake([ProcessPodcast])`.
63
+ `BusFake.fakeExcept([ShipOrder])`, from `@orieljs/framework/testing`, fakes all but those.
64
+
65
+ | Assertion | Passes when |
66
+ | --- | --- |
67
+ | `assertDispatched(job, filterOrCount?)`, `assertDispatchedTimes(job, times)` | The job was dispatched, matching the filter, or that many times |
68
+ | `assertNotDispatched(job, filter?)`, `assertNothingDispatched()` | It wasn't, or no job was |
69
+ | `assertDispatchedSync(job)`, `assertNotDispatchedSync(job)` | It was dispatched with `dispatchSync()`, or wasn't |
70
+ | `assertDispatchedAfterResponse(job)`, `assertNotDispatchedAfterResponse(job)` | It was dispatched to run after the response, or wasn't |
71
+ | `assertDispatchedWithoutChain(job)` | It was dispatched with no chain |
72
+
73
+ `bus.dispatched(ProcessPodcast)` returns the dispatched jobs, to check them with Vitest's `expect()`, and
74
+ `dispatchedSync()` and `dispatchedAfterResponse()` the others.
75
+
76
+ ### Job chains
77
+
78
+ `assertChained()` checks a chain, by class or by instance:
79
+
80
+ ```ts
81
+ import { BusFake } from '@orieljs/framework/testing'
82
+ import { OptimizePodcast } from './app/Jobs/OptimizePodcast'
83
+ import { ProcessPodcast } from './app/Jobs/ProcessPodcast'
84
+ import { PublishPodcast } from './app/Jobs/PublishPodcast'
85
+
86
+ declare const bus: BusFake
87
+ // ---cut---
88
+ bus.assertChained([ProcessPodcast, OptimizePodcast, PublishPodcast])
89
+ ```
90
+
91
+ An instance in the list matches a job of its class with the same fields.
92
+
93
+ ### Job batches
94
+
95
+ `assertBatched()` receives each batch, with its `name` and `jobs`, and passes when the function returns `true` for one:
96
+
97
+ ```ts
98
+ import { BusFake } from '@orieljs/framework/testing'
99
+
100
+ declare const bus: BusFake
101
+ // ---cut---
102
+ bus.assertBatched((batch) => batch.name === 'import-csv' && batch.jobs.length === 10)
103
+ ```
104
+
105
+ `assertBatchCount(count)` checks the number of batches, and `assertNothingBatched()` that there are none.
106
+
107
+ ## Event fake
108
+
109
+ `Event.fake()` records the events your code dispatches, and runs none of their listeners:
110
+
111
+ ```ts
112
+ // @filename: app/Events/OrderShipped.ts
113
+ export class OrderShipped {
114
+ constructor(readonly orderId: number) {}
115
+ }
116
+ // ---cut---
117
+ import { OrderShipped } from './app/Events/OrderShipped'
118
+
119
+ const events = Event.fake()
120
+
121
+ // Ship the order...
122
+
123
+ events.assertDispatched(OrderShipped, (event) => event.orderId === 1)
124
+ events.assertDispatchedTimes(OrderShipped, 1)
125
+ events.assertNotDispatched('order.cancelled')
126
+ ```
127
+
128
+ `assertNothingDispatched()` checks that no event was dispatched at all.
129
+
130
+ `assertListening(OrderShipped, SendShipmentNotification)` checks that a listener is registered for an event, and
131
+ `dispatched(event)` returns the dispatched events.
132
+
133
+ > [!WARNING]
134
+ > Model events, such as `creating` and `saved`, are events too. `Event.fake()` with no list stops them as well, so a
135
+ > model whose `creating` sets a value doesn't set it. Fake only the events you check: `Event.fake([OrderShipped])`.
136
+
137
+ `EventFake.fakeExcept([OrderCreated])`, from `@orieljs/framework/testing`, fakes every event but those.
138
+
139
+ ## Mail fake
140
+
141
+ `Mail.fake()` records each mail your code sends or queues, and sends nothing:
142
+
143
+ ```ts
144
+ import { OrderShipped } from './app/Mail/OrderShipped'
145
+
146
+ const mail = Mail.fake()
147
+
148
+ // Ship the order...
149
+
150
+ mail.assertSent(OrderShipped, (mailable, record) => record.hasTo('taylor@example.com'))
151
+ mail.assertSent(OrderShipped, 1)
152
+ mail.assertNotQueued(OrderShipped)
153
+ ```
154
+
155
+ The filter receives the mailable and its record, which has `hasTo()`, `hasCc()`, `hasBcc()` and `hasSubject()`. A
156
+ mailable that would be queued counts as queued, not sent.
157
+
158
+ | Assertion | Passes when |
159
+ | --- | --- |
160
+ | `assertSent(mailable, filterOrCount?)`, `assertSentTimes(mailable, times)` | The mail was sent, matching the filter, or that many times |
161
+ | `assertNotSent(mailable)`, `assertNothingSent()`, `assertSentCount(count)` | It wasn't sent, no mail was, or that many were |
162
+ | `assertQueued(mailable, filterOrCount?)`, `assertNotQueued(mailable)` | It was queued, or wasn't |
163
+ | `assertNothingQueued()`, `assertQueuedCount(count)` | No mail was queued, or that many were |
164
+ | `assertOutgoingCount(count)`, `assertNothingOutgoing()` | That many mails were sent or queued, or none |
165
+
166
+ `mail.sent(OrderShipped)` and `mail.queued(OrderShipped)` return the mailables. Nothing is rendered, so the fake
167
+ doesn't check your views.
168
+
169
+ ## Notification fake
170
+
171
+ `Notification.fake()` records each notification your code sends, and delivers none:
172
+
173
+ ```ts
174
+ import { User } from './app/Models/User'
175
+ import { InvoicePaid } from './app/Notifications/InvoicePaid'
176
+
177
+ const notifications = Notification.fake()
178
+
179
+ const user = await User.findOrFail(1)
180
+
181
+ // Pay the invoice...
182
+
183
+ notifications.assertSentTo(user, InvoicePaid, (notification, channels) => channels.includes('mail'))
184
+ notifications.assertNotSentTo(user, InvoicePaid, (notification) => notification.amount === '0')
185
+ notifications.assertCount(1)
186
+ ```
187
+
188
+ The filter receives the notification, its channels and the notifiable. `assertSentTo()` also takes a list of
189
+ notifiables, which must each have the notification.
190
+
191
+ | Assertion | Passes when |
192
+ | --- | --- |
193
+ | `assertSentTo(notifiable, notification, filterOrCount?)` | The notifiable was sent the notification |
194
+ | `assertSentToTimes(notifiable, notification, times)` | It was sent it that many times |
195
+ | `assertNotSentTo(notifiable, notification, filter?)`, `assertNothingSentTo(notifiable)` | It wasn't sent it, or anything |
196
+ | `assertSentTimes(notification, times)`, `assertCount(count)` | That many were sent, of that class or of all |
197
+ | `assertNothingSent()` | No notification was sent |
198
+ | `assertSentOnDemand(notification, filterOrCount?)` | It was sent to a route, with `Notification.route()` |
199
+
200
+ `notifications.sent(user, InvoicePaid)` returns the notifications the user was sent.
201
+
202
+ ## Queue fake
203
+
204
+ `Queue.fake()` records the jobs pushed on the queue, and runs none of them. Unlike `Bus.fake()`, it sees the queue
205
+ each job is pushed on:
206
+
207
+ ```ts
208
+ import { ProcessPodcast } from './app/Jobs/ProcessPodcast'
209
+
210
+ const queue = Queue.fake()
211
+
212
+ // Publish the podcast...
213
+
214
+ queue.assertPushed(ProcessPodcast, 1)
215
+ queue.assertPushedOn('audio', ProcessPodcast)
216
+ queue.assertPushed(ProcessPodcast, (job, queueName) => queueName === 'audio')
217
+ ```
218
+
219
+ | Assertion | Passes when |
220
+ | --- | --- |
221
+ | `assertPushed(job, filterOrCount?)`, `assertPushedTimes(job, times)` | The job was pushed, matching the filter, or that many times |
222
+ | `assertPushedOn(queue, job, filter?)` | It was pushed on that queue |
223
+ | `assertNotPushed(job, filter?)`, `assertNothingPushed()` | It wasn't, or no job was |
224
+ | `assertCount(count)` | That many jobs were pushed |
225
+ | `assertPushedWithChain(job, chain)`, `assertPushedWithoutChain(job)` | It was pushed with that chain after it, or with none |
226
+
227
+ `queue.pushed(ProcessPodcast)` returns the pushed jobs. Give `fake()` a list of jobs to fake only those.
228
+
229
+ ## Storage fake
230
+
231
+ `Storage.fake()` puts a disk in memory in place of one of yours, so a test writes no files, and returns it:
232
+
233
+ ```ts title="tests/Feature/Avatar.test.ts"
234
+ import { Storage } from '@orieljs/framework'
235
+ import { FakeFile } from '@orieljs/framework/testing'
236
+ import { test } from 'vitest'
237
+ import { TestCase } from '../TestCase'
238
+
239
+ const t = TestCase.boot()
240
+
241
+ test('albums can be uploaded', async () => {
242
+ const disk = Storage.fake('photos')
243
+
244
+ await t.post('/photos', { photos: [FakeFile.image('photo1.jpg'), FakeFile.image('photo2.jpg')] })
245
+
246
+ await disk.assertExists(['photo1.jpg', 'photo2.jpg'])
247
+ await disk.assertMissing('missing.jpg')
248
+ await disk.assertCount('', 2)
249
+ })
250
+ ```
251
+
252
+ With no name, it fakes the default disk. The assertions read the disk, so they are async: `await` each one.
253
+
254
+ | Assertion | Passes when |
255
+ | --- | --- |
256
+ | `assertExists(paths, contents?)` | Each file is on the disk, with those contents |
257
+ | `assertMissing(paths)` | No file is at the paths |
258
+ | `assertCount(folder, count, recursive?)` | The folder has that many files |
259
+ | `assertDirectoryEmpty(folder)` | The folder has no files |
260
+
261
+ ## Interacting with time
262
+
263
+ `t.travelTo()`, `t.travel()` and `t.freezeTime()` move the clock your code reads. [Time travel](time-travel.md) covers
264
+ them.