@cristiancorreau/forge 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +265 -109
  3. package/assets/adapters/claude-code/commands/laravel-eloquent.md +7 -0
  4. package/assets/adapters/claude-code/commands/laravel-mcp.md +7 -0
  5. package/assets/adapters/claude-code/commands/laravel-pest.md +7 -0
  6. package/assets/adapters/claude-code/commands/laravel-security.md +7 -0
  7. package/assets/adapters/claude-code/commands/laravel-verify.md +7 -0
  8. package/assets/core/hooks/pre-bash-check.js +46 -0
  9. package/assets/core/hooks/pre-edit-check.js +14 -0
  10. package/assets/core/skills/laravel-eloquent/SKILL.md +453 -0
  11. package/assets/core/skills/laravel-mcp/SKILL.md +468 -0
  12. package/assets/core/skills/laravel-pest/SKILL.md +686 -0
  13. package/assets/core/skills/laravel-security/SKILL.md +658 -0
  14. package/assets/core/skills/laravel-verify/SKILL.md +462 -0
  15. package/assets/manifest.json +27 -2
  16. package/assets/profiles/astro/agents/frontend-engineer.md +2 -0
  17. package/assets/profiles/django/agents/api-engineer.md +2 -0
  18. package/assets/profiles/expo/agents/mobile-engineer.md +2 -0
  19. package/assets/profiles/express/agents/api-engineer.md +2 -0
  20. package/assets/profiles/fastapi/agents/api-engineer.md +2 -0
  21. package/assets/profiles/flask/agents/api-engineer.md +2 -0
  22. package/assets/profiles/flutter/agents/mobile-engineer.md +12 -10
  23. package/assets/profiles/go-gin/agents/api-engineer.md +3 -1
  24. package/assets/profiles/hono-drizzle/agents/api-engineer.md +2 -0
  25. package/assets/profiles/laravel/README.md +16 -2
  26. package/assets/profiles/laravel/agents/api-engineer.md +2 -0
  27. package/assets/profiles/laravel/agents/fullstack-engineer.md +4 -2
  28. package/assets/profiles/laravel/agents/laravel-specialist.md +607 -0
  29. package/assets/profiles/laravel/agents/laravel-test-engineer.md +448 -0
  30. package/assets/profiles/nestjs/agents/api-engineer.md +3 -1
  31. package/assets/profiles/nextjs-admin/agents/admin-engineer.md +2 -0
  32. package/assets/profiles/playwright-crawler/agents/scanner-engineer.md +2 -0
  33. package/assets/profiles/rails/agents/fullstack-engineer.md +2 -0
  34. package/assets/profiles/rust/agents/api-engineer.md +2 -0
  35. package/assets/profiles/springboot/agents/api-engineer.md +11 -9
  36. package/assets/profiles/sveltekit/agents/frontend-engineer.md +4 -2
  37. package/assets/profiles/vuenuxt/agents/frontend-engineer.md +12 -10
  38. package/assets/profiles/wordpress/agents/divi-engineer.md +2 -0
  39. package/assets/profiles/wordpress/agents/elementor-engineer.md +2 -0
  40. package/dist/cli.js +10 -0
  41. package/dist/cli.js.map +1 -1
  42. package/dist/commands/add.d.ts +2 -0
  43. package/dist/commands/add.d.ts.map +1 -0
  44. package/dist/commands/add.js +187 -0
  45. package/dist/commands/add.js.map +1 -0
  46. package/dist/commands/mcp.d.ts +42 -0
  47. package/dist/commands/mcp.d.ts.map +1 -0
  48. package/dist/commands/mcp.js +141 -0
  49. package/dist/commands/mcp.js.map +1 -0
  50. package/dist/lib/catalog.d.ts.map +1 -1
  51. package/dist/lib/catalog.js +5 -0
  52. package/dist/lib/catalog.js.map +1 -1
  53. package/dist/lib/mcp-tools.d.ts +37 -0
  54. package/dist/lib/mcp-tools.d.ts.map +1 -0
  55. package/dist/lib/mcp-tools.js +124 -0
  56. package/dist/lib/mcp-tools.js.map +1 -0
  57. package/dist/lib/skill-security.d.ts +66 -0
  58. package/dist/lib/skill-security.d.ts.map +1 -0
  59. package/dist/lib/skill-security.js +225 -0
  60. package/dist/lib/skill-security.js.map +1 -0
  61. package/dist/lib/skill-source.d.ts +29 -0
  62. package/dist/lib/skill-source.d.ts.map +1 -0
  63. package/dist/lib/skill-source.js +94 -0
  64. package/dist/lib/skill-source.js.map +1 -0
  65. package/dist/tui/dashboard.d.ts.map +1 -1
  66. package/dist/tui/dashboard.js +3 -6
  67. package/dist/tui/dashboard.js.map +1 -1
  68. package/dist/tui/panel.d.ts.map +1 -1
  69. package/dist/tui/panel.js +7 -18
  70. package/dist/tui/panel.js.map +1 -1
  71. package/dist/tui/wizard.d.ts.map +1 -1
  72. package/dist/tui/wizard.js +3 -13
  73. package/dist/tui/wizard.js.map +1 -1
  74. package/dist/ui/colors.d.ts +3 -1
  75. package/dist/ui/colors.d.ts.map +1 -1
  76. package/dist/ui/colors.js +11 -2
  77. package/dist/ui/colors.js.map +1 -1
  78. package/dist/ui/header.d.ts.map +1 -1
  79. package/dist/ui/header.js +4 -3
  80. package/dist/ui/header.js.map +1 -1
  81. package/dist/ui/theme.d.ts +24 -0
  82. package/dist/ui/theme.d.ts.map +1 -0
  83. package/dist/ui/theme.js +32 -0
  84. package/dist/ui/theme.js.map +1 -0
  85. package/dist/version.d.ts +1 -1
  86. package/dist/version.js +1 -1
  87. package/package.json +2 -2
@@ -0,0 +1,686 @@
1
+ # Skill: laravel-pest
2
+
3
+ TDD con Pest 3 en Laravel: estructura `tests/Feature` y `tests/Unit`, `RefreshDatabase`,
4
+ factories y states, datasets, expectations, HTTP tests, fakes (`Mail`, `Queue`, `Event`,
5
+ `Http`, `Storage`), time travel y coverage con umbral mínimo. Activar al escribir o correr
6
+ tests en un proyecto Laravel.
7
+
8
+ Triggers: /laravel-pest, "escribir tests", "test con pest", "TDD en laravel",
9
+ "feature test", "model factory", "mockear cola/mail/evento", "fake de http",
10
+ "coverage de tests", "correr los tests".
11
+
12
+ ---
13
+
14
+ ## Cuándo usar este skill
15
+
16
+ - Al implementar una feature con TDD: el test se escribe junto con (o antes de) el código.
17
+ - Al crear o ajustar factories y states para preparar datos de prueba.
18
+ - Al testear endpoints HTTP (status, JSON, redirects, validación).
19
+ - Al aislar efectos secundarios con fakes (mail, queue, event, http, storage).
20
+ - Al verificar lógica dependiente del tiempo con time travel.
21
+ - Al medir cobertura y exigir un umbral mínimo en CI.
22
+
23
+ > Las versiones recientes de Laravel instalan Pest 3 por defecto; verifica el runner
24
+ > declarado en `composer.json`. Si el proyecto todavía usa PHPUnit puro, cada
25
+ > sección incluye la equivalencia. Pest corre sobre PHPUnit, así que ambos estilos
26
+ > conviven en el mismo `tests/` sin conflicto.
27
+
28
+ ---
29
+
30
+ ## Estructura de tests
31
+
32
+ Laravel ubica los tests en dos carpetas, configuradas como suites en `phpunit.xml`:
33
+
34
+ - `tests/Feature/` — ejercitan el framework completo (rutas, middleware, DB, container).
35
+ - `tests/Unit/` — clases aisladas sin booteo de Laravel; rápidos, sin acceso a DB.
36
+
37
+ El bootstrap de Pest vive en `tests/Pest.php`. Ahí se aplican traits y helpers por
38
+ carpeta con `uses()`, evitando repetirlos en cada archivo:
39
+
40
+ ```php
41
+ // tests/Pest.php
42
+ use Illuminate\Foundation\Testing\RefreshDatabase;
43
+ use Tests\TestCase;
44
+
45
+ // Todos los Feature tests heredan TestCase y refrescan la DB en cada test.
46
+ pest()->extend(TestCase::class)
47
+ ->use(RefreshDatabase::class)
48
+ ->in('Feature');
49
+
50
+ // Los Unit tests solo necesitan el TestCase base de PHPUnit (sin Laravel).
51
+ pest()->extend(PHPUnit\Framework\TestCase::class)
52
+ ->in('Unit');
53
+
54
+ // Expectation custom reutilizable en toda la suite.
55
+ expect()->extend('toBeOne', fn () => $this->toBe(1));
56
+ ```
57
+
58
+ Generar tests con artisan (las versiones recientes de Laravel scaffoldean en formato Pest por defecto):
59
+
60
+ ```bash
61
+ php artisan make:test ProductPurchaseTest --pest # tests/Feature (default)
62
+ php artisan make:test PricingTest --pest --unit # tests/Unit
63
+ php artisan make:test LegacyTest --phpunit # forzar clase PHPUnit
64
+ ```
65
+
66
+ Un Feature test mínimo en Pest:
67
+
68
+ ```php
69
+ <?php
70
+ // tests/Feature/HomeTest.php
71
+
72
+ it('muestra la home', function () {
73
+ $response = $this->get('/');
74
+
75
+ $response->assertOk();
76
+ });
77
+ ```
78
+
79
+ Equivalencia PHPUnit:
80
+
81
+ ```php
82
+ <?php
83
+ // tests/Feature/HomeTest.php
84
+ namespace Tests\Feature;
85
+
86
+ use Tests\TestCase;
87
+
88
+ class HomeTest extends TestCase
89
+ {
90
+ public function test_muestra_la_home(): void
91
+ {
92
+ $this->get('/')->assertOk();
93
+ }
94
+ }
95
+ ```
96
+
97
+ ---
98
+
99
+ ## RefreshDatabase y base de datos
100
+
101
+ `RefreshDatabase` envuelve cada test en una transacción y la revierte al terminar, dejando
102
+ el schema migrado una sola vez. Es el trait correcto para la mayoría de los Feature tests.
103
+
104
+ ```php
105
+ <?php
106
+ // tests/Feature/OrderTest.php
107
+ use Illuminate\Foundation\Testing\RefreshDatabase;
108
+
109
+ uses(RefreshDatabase::class); // omitir si ya está aplicado en tests/Pest.php
110
+
111
+ it('persiste una orden', function () {
112
+ $this->assertDatabaseCount('orders', 0);
113
+
114
+ Order::create(['total' => 1000, 'status' => 'pending']);
115
+
116
+ $this->assertDatabaseHas('orders', ['total' => 1000, 'status' => 'pending']);
117
+ $this->assertDatabaseCount('orders', 1);
118
+ });
119
+ ```
120
+
121
+ Traits relacionados, según el caso:
122
+
123
+ - `DatabaseTransactions` — envuelve en transacción pero NO migra; útil si la DB ya tiene schema.
124
+ - `DatabaseMigrations` — corre `migrate:fresh` antes de cada test (más lento; rara vez necesario).
125
+ - `RefreshDatabase` — lo recomendado: migra una vez, transacciona por test.
126
+
127
+ Aserciones de DB disponibles en cualquier test:
128
+
129
+ ```php
130
+ $this->assertDatabaseHas('users', ['email' => 'a@b.com']);
131
+ $this->assertDatabaseMissing('users', ['email' => 'baneado@b.com']);
132
+ $this->assertDatabaseCount('users', 3);
133
+ $this->assertModelExists($user);
134
+ $this->assertModelMissing($user); // tras delete()
135
+ $this->assertSoftDeleted($user); // con SoftDeletes
136
+ ```
137
+
138
+ Para tests veloces conviene una conexión SQLite en memoria en `phpunit.xml`:
139
+
140
+ ```xml
141
+ <php>
142
+ <env name="DB_CONNECTION" value="sqlite"/>
143
+ <env name="DB_DATABASE" value=":memory:"/>
144
+ </php>
145
+ ```
146
+
147
+ > Si el código bajo prueba usa features específicas de PostgreSQL (pgvector,
148
+ > `whereVectorSimilarTo`, JSON operators), NO uses SQLite en memoria: apunta a una DB
149
+ > de test Postgres real, porque SQLite no soporta esa sintaxis.
150
+
151
+ ---
152
+
153
+ ## Model factories y states
154
+
155
+ Las factories generan datos de prueba realistas. Se generan con artisan y viven en
156
+ `database/factories/`:
157
+
158
+ ```bash
159
+ php artisan make:factory ProductFactory
160
+ php artisan make:model Product -f # modelo + factory de una vez
161
+ ```
162
+
163
+ Una factory en Laravel declara `definition()` y opcionalmente `states` como métodos
164
+ que devuelven `$this->state(...)`:
165
+
166
+ ```php
167
+ <?php
168
+ // database/factories/ProductFactory.php
169
+ namespace Database\Factories;
170
+
171
+ use Illuminate\Database\Eloquent\Factories\Factory;
172
+
173
+ class ProductFactory extends Factory
174
+ {
175
+ public function definition(): array
176
+ {
177
+ return [
178
+ 'name' => fake()->words(3, true),
179
+ 'price' => fake()->numberBetween(100, 50_000),
180
+ 'stock' => fake()->numberBetween(0, 100),
181
+ 'is_active' => true,
182
+ 'published_at' => now(),
183
+ ];
184
+ }
185
+
186
+ // State: producto agotado.
187
+ public function outOfStock(): static
188
+ {
189
+ return $this->state(fn (array $attributes) => ['stock' => 0]);
190
+ }
191
+
192
+ // State: borrador (no publicado).
193
+ public function draft(): static
194
+ {
195
+ return $this->state(fn (array $attributes) => [
196
+ 'is_active' => false,
197
+ 'published_at' => null,
198
+ ]);
199
+ }
200
+ }
201
+ ```
202
+
203
+ Usar la factory en los tests:
204
+
205
+ ```php
206
+ $product = Product::factory()->create(); // uno persistido
207
+ $product = Product::factory()->make(); // uno sin persistir
208
+ $products = Product::factory()->count(5)->create(); // colección
209
+
210
+ // States encadenados:
211
+ $agotado = Product::factory()->outOfStock()->create();
212
+ $borrador = Product::factory()->draft()->outOfStock()->create();
213
+
214
+ // Sobrescribir atributos puntuales:
215
+ $caro = Product::factory()->create(['price' => 99_000]);
216
+
217
+ // Sequences para variar valores entre instancias:
218
+ use Illuminate\Database\Eloquent\Factories\Sequence;
219
+
220
+ Product::factory()
221
+ ->count(4)
222
+ ->state(new Sequence(['is_active' => true], ['is_active' => false]))
223
+ ->create();
224
+ ```
225
+
226
+ Relaciones con factories:
227
+
228
+ ```php
229
+ // hasMany: una orden con 3 ítems.
230
+ $order = Order::factory()
231
+ ->has(OrderItem::factory()->count(3))
232
+ ->create();
233
+
234
+ // Atajo por nombre de relación + magic method:
235
+ $order = Order::factory()
236
+ ->hasItems(3, ['quantity' => 2])
237
+ ->create();
238
+
239
+ // belongsTo: ítems que pertenecen a una orden existente.
240
+ OrderItem::factory()->count(3)->for($order)->create();
241
+
242
+ // Pivot (belongsToMany) con datos de la tabla intermedia:
243
+ $user = User::factory()
244
+ ->hasAttached(Role::factory()->count(2), ['assigned_at' => now()])
245
+ ->create();
246
+ ```
247
+
248
+ El modelo debe usar el trait `HasFactory` (incluido por defecto en los modelos de Laravel):
249
+
250
+ ```php
251
+ use Illuminate\Database\Eloquent\Factories\HasFactory;
252
+
253
+ class Product extends Model
254
+ {
255
+ use HasFactory;
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Datasets
262
+
263
+ Los datasets ejecutan el mismo test con múltiples entradas, generando un caso por fila.
264
+ Reemplazan a `@dataProvider` de PHPUnit con menos boilerplate.
265
+
266
+ ```php
267
+ it('valida emails', function (string $email, bool $valido) {
268
+ expect(filter_var($email, FILTER_VALIDATE_EMAIL) !== false)->toBe($valido);
269
+ })->with([
270
+ ['a@b.com', true],
271
+ ['sin-arroba', false],
272
+ ['x@y.cl', true],
273
+ ]);
274
+ ```
275
+
276
+ Con claves nombradas (mejoran la salida del runner):
277
+
278
+ ```php
279
+ it('calcula descuento por tier', function (string $tier, int $esperado) {
280
+ expect(discountFor($tier))->toBe($esperado);
281
+ })->with([
282
+ 'free' => ['free', 0],
283
+ 'pro' => ['pro', 10],
284
+ 'premium' => ['premium', 25],
285
+ ]);
286
+ ```
287
+
288
+ Datasets reutilizables registrados en `tests/Pest.php`:
289
+
290
+ ```php
291
+ // tests/Pest.php
292
+ dataset('emails_invalidos', ['sin-arroba', 'a@', '@b.com', '']);
293
+ ```
294
+
295
+ ```php
296
+ it('rechaza emails inválidos en el registro', function (string $email) {
297
+ $this->post('/register', ['email' => $email, 'password' => 'secret123'])
298
+ ->assertSessionHasErrors('email');
299
+ })->with('emails_invalidos');
300
+ ```
301
+
302
+ Datasets que devuelven modelos (lazy, se evalúan dentro del test, no al colectar):
303
+
304
+ ```php
305
+ it('no deja comprar productos inactivos', function (Closure $product) {
306
+ $this->actingAs(User::factory()->create())
307
+ ->post('/cart', ['product_id' => $product()->id])
308
+ ->assertForbidden();
309
+ })->with([
310
+ fn () => Product::factory()->draft()->create(),
311
+ fn () => Product::factory()->outOfStock()->create(),
312
+ ]);
313
+ ```
314
+
315
+ Equivalencia PHPUnit (data provider):
316
+
317
+ ```php
318
+ public static function emailProvider(): array
319
+ {
320
+ return [['a@b.com', true], ['sin-arroba', false]];
321
+ }
322
+
323
+ #[\PHPUnit\Framework\Attributes\DataProvider('emailProvider')]
324
+ public function test_valida_emails(string $email, bool $valido): void
325
+ {
326
+ $this->assertSame($valido, filter_var($email, FILTER_VALIDATE_EMAIL) !== false);
327
+ }
328
+ ```
329
+
330
+ ---
331
+
332
+ ## Expectations
333
+
334
+ La API de expectations de Pest es encadenable y legible. Equivale a los `assert*` de PHPUnit.
335
+
336
+ ```php
337
+ expect($user->name)->toBe('Ada');
338
+ expect($total)->toBeInt()->toBeGreaterThan(0);
339
+ expect($collection)->toHaveCount(3);
340
+ expect($product->is_active)->toBeTrue();
341
+ expect($response->json())->toBeArray()->toHaveKey('data');
342
+ expect($order->status)->toBeIn(['pending', 'paid', 'shipped']);
343
+ expect($email)->toMatch('/@/');
344
+ expect(fn () => throw new RuntimeException('boom'))->toThrow(RuntimeException::class, 'boom');
345
+
346
+ // Negación e inversión:
347
+ expect($user->email)->not->toBeEmpty();
348
+
349
+ // Encadenar sobre la misma colección con and():
350
+ expect($product)
351
+ ->name->toBe('Teclado')
352
+ ->and($product->price)->toBeGreaterThan(0)
353
+ ->and($product->stock)->toBeInt();
354
+ ```
355
+
356
+ Tabla de equivalencias frecuentes:
357
+
358
+ | PHPUnit | Pest |
359
+ |----------------------------------|---------------------------------------|
360
+ | `assertSame($a, $b)` | `expect($b)->toBe($a)` |
361
+ | `assertEquals($a, $b)` | `expect($b)->toEqual($a)` |
362
+ | `assertTrue($x)` | `expect($x)->toBeTrue()` |
363
+ | `assertNull($x)` | `expect($x)->toBeNull()` |
364
+ | `assertCount(3, $c)` | `expect($c)->toHaveCount(3)` |
365
+ | `assertInstanceOf(X::class, $y)` | `expect($y)->toBeInstanceOf(X::class)`|
366
+ | `expectException(E::class)` | `...->toThrow(E::class)` |
367
+
368
+ > Dentro de un test Pest, `$this` es la instancia `TestCase`, así que las aserciones de
369
+ > Laravel (`$this->assertDatabaseHas`, `$this->get`) conviven con `expect()` sin problema.
370
+
371
+ ---
372
+
373
+ ## HTTP tests
374
+
375
+ Los Feature tests ejercitan rutas reales pasando por middleware y container. Recuerda que
376
+ en las versiones recientes de Laravel el middleware (incluido el de CSRF, `Illuminate\Foundation\Http\Middleware\ValidateCsrfToken`)
377
+ se configura en `bootstrap/app.php` — la estructura slim ya no expone `app/Http/Kernel.php`;
378
+ verifica leyendo `bootstrap/app.php`.
379
+
380
+ ```php
381
+ it('crea un producto autenticado', function () {
382
+ $admin = User::factory()->create(['is_admin' => true]);
383
+
384
+ $response = $this->actingAs($admin)->postJson('/api/products', [
385
+ 'name' => 'Teclado mecánico',
386
+ 'price' => 45_000,
387
+ ]);
388
+
389
+ $response
390
+ ->assertCreated() // 201
391
+ ->assertJsonPath('data.name', 'Teclado mecánico')
392
+ ->assertJsonStructure(['data' => ['id', 'name', 'price']]);
393
+
394
+ $this->assertDatabaseHas('products', ['name' => 'Teclado mecánico']);
395
+ });
396
+ ```
397
+
398
+ Verbos y aserciones HTTP más usados:
399
+
400
+ ```php
401
+ $this->get('/dashboard')->assertOk(); // 200
402
+ $this->get('/admin')->assertForbidden(); // 403
403
+ $this->get('/no-existe')->assertNotFound(); // 404
404
+ $this->post('/login', [...])->assertRedirect('/home'); // 302 a destino
405
+ $this->getJson('/api/users')->assertStatus(200);
406
+
407
+ // JSON:
408
+ $this->getJson('/api/products/1')
409
+ ->assertJson(['data' => ['id' => 1]]) // subconjunto
410
+ ->assertJsonCount(10, 'data') // cantidad en una clave
411
+ ->assertJsonPath('data.0.name', 'Mouse')
412
+ ->assertJsonFragment(['status' => 'active'])
413
+ ->assertJsonMissing(['password' => '*']);
414
+
415
+ // Validación fallida (422 en API, errores de sesión en web):
416
+ $this->postJson('/api/products', [])
417
+ ->assertStatus(422)
418
+ ->assertJsonValidationErrors(['name', 'price']);
419
+
420
+ $this->post('/products', [])
421
+ ->assertSessionHasErrors(['name']);
422
+
423
+ // Headers, cookies, auth:
424
+ $this->withHeaders(['X-Trace' => 'abc'])->getJson('/api/ping');
425
+ $this->assertAuthenticated();
426
+ $this->assertGuest();
427
+ ```
428
+
429
+ Autenticación en API token-based (Sanctum, el default de Laravel para SPA/mobile):
430
+
431
+ ```php
432
+ use Laravel\Sanctum\Sanctum;
433
+
434
+ it('lista órdenes del usuario autenticado', function () {
435
+ $user = User::factory()->has(Order::factory()->count(2))->create();
436
+
437
+ Sanctum::actingAs($user, ['orders:read']);
438
+
439
+ $this->getJson('/api/orders')
440
+ ->assertOk()
441
+ ->assertJsonCount(2, 'data');
442
+ });
443
+ ```
444
+
445
+ ---
446
+
447
+ ## Fakes
448
+
449
+ Los fakes interceptan servicios para asertar interacciones sin ejecutar efectos reales
450
+ (no se envían mails, no se encolan jobs, no se sube nada). Se activan al inicio del test.
451
+
452
+ ### Mail
453
+
454
+ ```php
455
+ use Illuminate\Support\Facades\Mail;
456
+ use App\Mail\OrderShipped;
457
+
458
+ it('envía el mail de despacho', function () {
459
+ Mail::fake();
460
+
461
+ $order = Order::factory()->create();
462
+ OrderService::ship($order);
463
+
464
+ Mail::assertSent(OrderShipped::class, function (OrderShipped $mail) use ($order) {
465
+ return $mail->order->is($order)
466
+ && $mail->hasTo($order->user->email);
467
+ });
468
+ Mail::assertSentCount(1);
469
+ Mail::assertNotSent(\App\Mail\OrderCancelled::class);
470
+ });
471
+ ```
472
+
473
+ ### Queue
474
+
475
+ ```php
476
+ use Illuminate\Support\Facades\Queue;
477
+ use App\Jobs\ProcessPodcast;
478
+
479
+ it('encola el procesamiento del podcast', function () {
480
+ Queue::fake();
481
+
482
+ Podcast::factory()->create()->process();
483
+
484
+ Queue::assertPushed(ProcessPodcast::class);
485
+ Queue::assertPushedOn('podcasts', ProcessPodcast::class); // en cola específica
486
+ Queue::assertPushed(ProcessPodcast::class, 1);
487
+ Queue::assertNothingPushed(); // si esperas 0
488
+ });
489
+ ```
490
+
491
+ ### Event
492
+
493
+ ```php
494
+ use Illuminate\Support\Facades\Event;
495
+ use App\Events\OrderPaid;
496
+
497
+ it('dispara OrderPaid al pagar', function () {
498
+ Event::fake([OrderPaid::class]); // fakear solo este evento, dejar correr el resto
499
+
500
+ $order = Order::factory()->create();
501
+ $order->markAsPaid();
502
+
503
+ Event::assertDispatched(OrderPaid::class, fn (OrderPaid $e) => $e->order->is($order));
504
+ Event::assertDispatchedTimes(OrderPaid::class, 1);
505
+ });
506
+ ```
507
+
508
+ ### Http (cliente HTTP saliente)
509
+
510
+ ```php
511
+ use Illuminate\Support\Facades\Http;
512
+
513
+ it('consulta la API de pagos y maneja la respuesta', function () {
514
+ Http::fake([
515
+ 'api.pagos.test/charge' => Http::response(['id' => 'ch_123', 'paid' => true], 200),
516
+ 'api.pagos.test/*' => Http::response([], 404),
517
+ ]);
518
+
519
+ $result = PaymentGateway::charge(amount: 1000);
520
+
521
+ expect($result['id'])->toBe('ch_123');
522
+
523
+ Http::assertSent(fn ($request) =>
524
+ $request->url() === 'https://api.pagos.test/charge'
525
+ && $request['amount'] === 1000
526
+ );
527
+ Http::assertSentCount(1);
528
+ });
529
+
530
+ // Prevenir cualquier llamada real no fakeada (recomendado en CI):
531
+ Http::preventStrayRequests();
532
+ ```
533
+
534
+ ### Storage
535
+
536
+ ```php
537
+ use Illuminate\Support\Facades\Storage;
538
+ use Illuminate\Http\UploadedFile;
539
+
540
+ it('guarda el avatar subido', function () {
541
+ Storage::fake('public');
542
+
543
+ $this->actingAs(User::factory()->create())
544
+ ->post('/profile/avatar', [
545
+ 'avatar' => UploadedFile::fake()->image('me.jpg', 200, 200),
546
+ ])
547
+ ->assertRedirect();
548
+
549
+ Storage::disk('public')->assertExists('avatars/me.jpg');
550
+ Storage::disk('public')->assertMissing('avatars/viejo.jpg');
551
+ });
552
+ ```
553
+
554
+ > Ordena los fakes según lo que pruebas: si testeas que algo se ENCOLA, usa `Queue::fake()`.
555
+ > Si testeas que el job HACE su trabajo, NO lo fakees: ejecútalo (`Bus::dispatchSync()` o
556
+ > llamando `handle()`) y asevera el efecto.
557
+
558
+ ---
559
+
560
+ ## Time travel
561
+
562
+ Para lógica que depende de fechas (vencimientos, trials, scheduling), congela o avanza el
563
+ reloj con los helpers de `TestCase`. Internamente usan Carbon test-now.
564
+
565
+ ```php
566
+ it('marca el trial como vencido a los 14 días', function () {
567
+ $user = freezeTime(function () { // congela "ahora" durante el closure
568
+ return User::factory()->create(['trial_ends_at' => now()->addDays(14)]);
569
+ });
570
+
571
+ $this->travel(13)->days();
572
+ expect($user->fresh()->onTrial())->toBeTrue();
573
+
574
+ $this->travel(2)->days(); // total: día 15
575
+ expect($user->fresh()->onTrial())->toBeFalse();
576
+
577
+ $this->travelBack(); // restaurar el reloj real
578
+ });
579
+ ```
580
+
581
+ Variantes:
582
+
583
+ ```php
584
+ $this->travelTo(now()->startOfYear()); // saltar a un instante exacto
585
+ $this->travel(5)->hours();
586
+ $this->travel(-1)->weeks();
587
+
588
+ // Ejecutar algo en un instante fijo y volver automáticamente:
589
+ $this->travelTo(Carbon\Carbon::parse('2026-12-31 23:59:00'), function () {
590
+ expect(now()->year)->toBe(2026);
591
+ });
592
+
593
+ // Congelar sin avanzar (útil para evitar drift de microsegundos):
594
+ $this->freezeTime();
595
+ ```
596
+
597
+ Equivalencia PHPUnit: idénticos métodos (`$this->travel`, `$this->travelTo`,
598
+ `$this->freezeTime`) porque vienen del trait `InteractsWithTime` del `TestCase` base.
599
+
600
+ ---
601
+
602
+ ## Lifecycle hooks (setup/teardown)
603
+
604
+ ```php
605
+ beforeEach(function () {
606
+ $this->user = User::factory()->create(); // disponible en cada test del archivo
607
+ });
608
+
609
+ afterEach(function () {
610
+ // limpieza si hiciera falta (RefreshDatabase ya revierte la DB)
611
+ });
612
+
613
+ beforeAll(fn () => /* una vez antes de todos */);
614
+ afterAll(fn () => /* una vez al final */);
615
+ ```
616
+
617
+ Equivalen a `setUp()` / `tearDown()` de PHPUnit (recuerda llamar `parent::setUp()` si
618
+ sobrescribes en una clase).
619
+
620
+ ---
621
+
622
+ ## Correr los tests
623
+
624
+ ```bash
625
+ ./vendor/bin/pest # toda la suite
626
+ ./vendor/bin/pest tests/Feature/OrderTest.php # un archivo
627
+ ./vendor/bin/pest --filter="encola" # por nombre/descripción
628
+ ./vendor/bin/pest --group=slow # tests con ->group('slow')
629
+ ./vendor/bin/pest --parallel # en paralelo (usa varios procesos)
630
+ ./vendor/bin/pest --bail # detener en el primer fallo
631
+ ./vendor/bin/pest --retry # reejecutar solo los que fallaron
632
+
633
+ php artisan test # wrapper de artisan (usa Pest si está)
634
+ php artisan test --filter=OrderTest
635
+ php artisan test --parallel
636
+ ```
637
+
638
+ ---
639
+
640
+ ## Coverage con umbral mínimo
641
+
642
+ Requiere un driver de cobertura (`Xdebug` con `XDEBUG_MODE=coverage`, o `PCOV`).
643
+
644
+ ```bash
645
+ # Reporte en consola:
646
+ ./vendor/bin/pest --coverage
647
+
648
+ # Exigir un mínimo: falla (exit code != 0) si la cobertura baja del umbral.
649
+ ./vendor/bin/pest --coverage --min=80
650
+
651
+ # Reporte HTML o Clover para CI:
652
+ ./vendor/bin/pest --coverage --coverage-html=build/coverage
653
+ ./vendor/bin/pest --coverage-clover=build/clover.xml
654
+
655
+ # Métrica de "type coverage" (porcentaje de tipos declarados, feature de Pest 3):
656
+ ./vendor/bin/pest --type-coverage --min=100
657
+ ```
658
+
659
+ En CI conviene fijar el driver y el umbral explícitamente:
660
+
661
+ ```bash
662
+ XDEBUG_MODE=coverage ./vendor/bin/pest --coverage --min=80 --parallel
663
+ ```
664
+
665
+ > Si `--coverage` reporta 0% o avisa que no hay driver, falta Xdebug/PCOV o
666
+ > `XDEBUG_MODE=coverage`. Sin driver, el flag `--coverage` no produce números reales.
667
+
668
+ ---
669
+
670
+ ## Workflow TDD recomendado
671
+
672
+ 1. Escribir el test primero en `tests/Feature/` (rojo): describe el comportamiento esperado.
673
+ 2. Preparar datos con factories y states; usar fakes para aislar efectos externos.
674
+ 3. Implementar el código mínimo para pasar (verde).
675
+ 4. Refactorizar con el test como red de seguridad.
676
+ 5. Antes de cerrar: `./vendor/bin/pest --coverage --min=<umbral>` y `./vendor/bin/pint --test`.
677
+
678
+ ## Qué NO hacer
679
+
680
+ - No fakees el servicio que estás probando: si testeas que un job hace su trabajo, ejecútalo.
681
+ - No dependas del orden entre tests: cada uno arranca con DB limpia (`RefreshDatabase`).
682
+ - No uses `now()` real en aserciones de tiempo: congela el reloj con `freezeTime()`/`travelTo()`.
683
+ - No referencies `app/Http/Kernel.php` — la estructura slim de Laravel ya no lo expone (el middleware va en `bootstrap/app.php`); verifica leyendo `bootstrap/app.php`.
684
+ - No corras `--coverage` sin driver: el número será falso (0%) y no protege nada.
685
+ - No metas llamadas HTTP reales en tests: `Http::fake()` + `Http::preventStrayRequests()`.
686
+ - No uses SQLite en memoria si el código bajo prueba depende de Postgres (pgvector, JSON ops).