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%) ✓