Cultiva Market · Test Suite
Laravel 11 + PHPUnit/Pest · TDD · API SaaS B2B
PASSING · 42/42
🔴
RED
Escribe test fallido
→
🟢
GREEN
Implementa lo mínimo
→
🔵
REFACTOR
Mejora sin romper
→
🔁
REPEAT
42
Tests Pasados
0
Fallidos
1
Omitidos
87%
Cobertura
1.24s
Tiempo Total
Cobertura por componente
Modelos
96%
Controladores
88%
Servicios
91%
Policies
100%
Form Requests
85%
AuthControllerTest — Resultados 312ms
✓
✎ test_users_can_register 2 asserts 18ms
✓
✎ test_users_can_login 2 asserts 24ms
✓
✎ test_users_cannot_login_with_wrong_password 1 assert 9ms
✓
✎ test_token_bearer_authenticates_requests 2 asserts 15ms
✓
✎ test_unauthenticated_requests_are_rejected 1 assert 6ms
✓
✎ test_duplicate_email_registration_fails 2 asserts 12ms
✓
✎ test_logout_revokes_token 2 asserts 11ms
✓
✎ test_password_reset_email_is_sent 1 assert 8ms
📄 phpunit.xml Configuración
1<?xml version="1.0" encoding="UTF-8"?>
2<phpunit bootstrap="vendor/autoload.php"
3         colors="true"
4         stopOnFailure="false">
5  <testsuites>
6    <testsuite name="Unit">
7      <directory suffix="Test.php">tests/Unit</directory>
8    </testsuite>
9    <testsuite name="Feature">
10      <directory suffix="Test.php">tests/Feature</directory>
11    </testsuite>
12  </testsuites>
13  <php>
14    <env name="APP_ENV"        value="testing"/>
15    <env name="BCRYPT_ROUNDS"   value="4"/>       <!-- Faster hashing in tests -->
16    <env name="CACHE_STORE"     value="array"/>
17    <env name="DB_CONNECTION"   value="sqlite"/>
18    <env name="DB_DATABASE"     value=":memory:"/>   <!-- In-memory SQLite -->
19    <env name="MAIL_MAILER"     value="array"/>
20    <env name="QUEUE_CONNECTION" value="sync"/>
21  </php>
22</phpunit>
🧪 tests/Feature/Http/Controllers/Api/AuthControllerTest.php Feature
1namespace Tests\Feature\Http\Controllers\Api;

3use App\Models\User;
4use Illuminate\Foundation\Testing\RefreshDatabase;
5use Illuminate\Support\Facades\Mail;
6use App\Mail\PasswordResetMail;
7use Tests\TestCase;

9class AuthControllerTest extends TestCase
10{
11    use RefreshDatabase;

13    // ─── RED → GREEN: Registro de nueva agencia ───────────────────────
14    public function test_users_can_register(): void
15    {
16        $response = $this->postJson('/api/register', [
17            'name'                  => 'Agencia Moderna SL',
18            'email'                 => 'hola@agenciamoderna.es',
19            'password'              => 'Secreto123!',
20            'password_confirmation' => 'Secreto123!',
21        ]);

23        $response->assertCreated();
24        $response->assertJsonStructure(['data' => ['user', 'token']]);
25    }

27    public function test_users_can_login(): void
28    {
29        User::factory()->create([
30            'email'    => 'ceo@cultivamarket.io',
31            'password' => Hash::make('Cultiva2024!'),
32        ]);

34        $response = $this->postJson('/api/login', [
35            'email'    => 'ceo@cultivamarket.io',
36            'password' => 'Cultiva2024!',
37        ]);

39        $response->assertOk();
40        $response->assertJsonStructure(['data' => ['token']]);
41    }

43    // ─── Mail Fake: reset de contraseña ───────────────────────────────
44    public function test_password_reset_email_is_sent(): void
45    {
46        Mail::fake();
47        $user = User::factory()->create();

49        $this->postJson('/api/forgot-password', ['email' => $user->email])
50             ->assertOk();

52        Mail::assertSent(PasswordResetMail::class, fn($m) => $m->hasTo($user->email));
53    }
54}
🧪 tests/Feature/Http/Controllers/Api/CampaignControllerTest.php Feature
1namespace Tests\Feature\Http\Controllers\Api;

3use App\Models\{Agency, Campaign, User};
4use Illuminate\Foundation\Testing\RefreshDatabase;
5use Tests\TestCase;

7class CampaignControllerTest extends TestCase
8{
9    use RefreshDatabase;

11    public function test_agency_can_create_campaign(): void
12    {
13        $user = $this->actingAsUser();

15        $response = $this->postJson('/api/campaigns', [
16            'name'       => 'Campaña Q3 2024 — Cultiva IA',
17            'budget'     => 5000_00,  // céntimos
18            'channel'    => 'meta_ads',
19            'starts_at'  => '2024-07-01',
20            'ends_at'    => '2024-09-30',
21        ]);

23        $response->assertCreated();
24        $response->assertJsonPath('data.name', 'Campaña Q3 2024 — Cultiva IA');
25        $this->assertDatabaseHas('campaigns', ['user_id' => $user->id]);
26    }

28    public function test_agencies_cannot_read_others_campaigns(): void
29    {
30        $owner    = User::factory()->create();
31        $attacker = User::factory()->create();
32        $campaign = Campaign::factory()->create(['user_id' => $owner->id]);

34        $this->actingAs($attacker)
35             ->getJson("/api/campaigns/{$campaign->id}")
36             ->assertForbidden();
37    }

39    public function test_it_paginates_campaigns(): void
40    {
41        $user = $this->actingAsUser();
42        Campaign::factory()->count(20)->create(['user_id' => $user->id]);

44        $this->getJson('/api/campaigns?page=1')
45             ->assertOk()
46             ->assertJsonCount(15, 'data')  // 15 por página
47             ->assertJsonStructure([
48                 'data' => [['id','name','budget','channel','status']],
49                 'meta' => ['current_page','last_page','total'],
50             ]);
51    }
52}
🧪 tests/Unit/Services/PaymentServiceTest.php Unit
1namespace Tests\Unit\Services;

3use App\Services\PaymentService;
4use App\Exceptions\PaymentFailedException;
5use Illuminate\Support\Facades\Http;
6use Tests\TestCase;

8class PaymentServiceTest extends TestCase
9{
10    // ─── RED: test escrito antes del servicio ─────────────────────────

12    public function test_it_handles_successful_stripe_charge(): void
13    {
14        Http::fake([
15            'api.stripe.com/*' => Http::response([
16                'id'     => 'pi_3Q2AABCDE12345',
17                'status' => 'succeeded',
18                'amount' => 2999,
19            ], 200),
20        ]);

22        $result = (new PaymentService())->charge(2999);

24        $this->assertTrue($result->success);
25        $this->assertEquals('pi_3Q2AABCDE12345', $result->paymentIntentId);
26    }

28    public function test_it_throws_on_card_declined(): void
29    {
30        Http::fake([
31            'api.stripe.com/*' => Http::response([
32                'error' => ['code' => 'card_declined']
33            ], 402),
34        ]);

36        $this->expectException(PaymentFailedException::class);
37        (new PaymentService())->charge(2999);
38    }

40    public function test_it_retries_on_timeout(): void
41    {
42        Http::fake([
43            'api.stripe.com/*' => Http::sequence()
44                ->pushStatus(408)   // primer intento: timeout
45                ->push(['id'=>'pi_retry_ok','status'=>'succeeded'], 200),
46        ]);

48        $result = (new PaymentService())->charge(2999);
49        $this->assertTrue($result->success);
50    }
51}
🧪 tests/Feature/campaigns.pest.php Pest
1use App\Models\{Campaign, User};
2use Illuminate\Support\Facades\Queue;
3use App\Jobs\GenerateCampaignReport;

5uses(Illuminate\Foundation\Testing\RefreshDatabase::class);

7beforeEach(function () {
8    $this->user = User::factory()->create();
9    $this->actingAs($this->user);
10});

12it('lists only own campaigns', function () {
13    Campaign::factory()->count(3)->create(['user_id' => $this->user->id]);
14    Campaign::factory()->count(5)->create();  // otras agencias

16    $this->getJson('/api/campaigns')
17         ->assertOk()
18         ->assertJsonCount(3, 'data');  // solo las 3 propias
19});

21it('dispatches report job when campaign ends', function () {
22    Queue::fake();
23    $campaign = Campaign::factory()->create(['user_id' => $this->user->id]);

25    $this->patchJson("/api/campaigns/{$campaign->id}/finish")
26         ->assertOk();

28    Queue::assertPushed(GenerateCampaignReport::class,
29        fn($job) => $job->campaign->id === $campaign->id
30    );
31});

33it('validates budget is positive', function () {
34    $this->postJson('/api/campaigns', ['budget' => -100])
35         ->assertUnprocessable()
36         ->assertJsonValidationErrors(['name', 'budget']);
37});
$ vendor/bin/pest --coverage --min=85 PASS Tests\Unit\Models\CampaignModelTest ✓ active scope filters correctly 4ms ✓ it belongs to an agency 3ms ✓ budget accessor returns formatted eur 2ms PASS Tests\Unit\Services\PaymentServiceTest ✓ it handles successful stripe charge 6ms ✓ it throws on card declined 4ms ✓ it retries on timeout 5ms PASS Tests\Feature\Http\Controllers\Api\AuthControllerTest ✓ users can register 18ms ✓ users can login 24ms ✓ users cannot login with wrong password 9ms ✓ token bearer authenticates requests 15ms ✓ logout revokes token 11ms ✓ password reset email is sent 8ms PASS Tests\Feature\Http\Controllers\Api\CampaignControllerTest ✓ agency can create campaign 22ms ✓ agencies cannot read others campaigns 14ms ✓ it paginates campaigns 31ms PASS Tests\Feature (Pest) ✓ lists only own campaigns 19ms ✓ dispatches report job when campaign ends 17ms ✓ validates budget is positive 8ms ⚠ generate report pdf skipped (xdebug missing in CI) — ──────────────────────────────────────────────────────── Tests: 42 passed, 1 skipped Duration: 1.24s Coverage: 87.3% (min: 85%) ✓