--- description: Testing standards for EventCrew multi-tenant platform globs: ["**/tests/**", "**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx", "**/Test.php"] alwaysApply: true --- # Testing Standards ## Philosophy 1. **Test behavior, not implementation** - Focus on what, not how 2. **Feature tests for APIs** - Test full request/response cycles 3. **Multi-tenant isolation** - Every test must verify organisation scoping 4. **Minimum per endpoint**: happy path (200/201) + unauthenticated (401) + wrong organisation (403) + validation (422) 5. **PHPUnit** for backend, **Vitest** for frontend ## Laravel Testing ### Directory Structure ``` api/tests/ ├── Feature/ │ └── Api/ │ └── V1/ │ ├── AuthTest.php │ ├── OrganisationTest.php │ ├── EventTest.php │ ├── FestivalSectionTest.php │ ├── TimeSlotTest.php │ ├── ShiftTest.php │ ├── PersonTest.php │ ├── ArtistTest.php │ ├── AccreditationTest.php │ └── BriefingTest.php ├── Unit/ │ ├── Services/ │ └── Models/ └── TestCase.php ``` ### Feature Test Template (Multi-Tenant) ```php 'org_admin']); Role::create(['name' => 'org_member']); // Create organisations $this->organisation = Organisation::factory()->create(); $this->otherOrganisation = Organisation::factory()->create(); // Create users with Spatie roles $this->orgAdmin = User::factory()->create(); $this->orgAdmin->assignRole('org_admin'); $this->organisation->users()->attach($this->orgAdmin); $this->orgMember = User::factory()->create(); $this->orgMember->assignRole('org_member'); $this->organisation->users()->attach($this->orgMember); $this->otherOrgUser = User::factory()->create(); $this->otherOrgUser->assignRole('org_admin'); $this->otherOrganisation->users()->attach($this->otherOrgUser); } // ========================================== // AUTHENTICATION (401) // ========================================== public function test_unauthenticated_user_cannot_list_events(): void { $this->getJson("/api/v1/organisations/{$this->organisation->id}/events") ->assertStatus(401); } public function test_unauthenticated_user_cannot_create_event(): void { $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", []) ->assertStatus(401); } // ========================================== // ORGANISATION ISOLATION (403) // ========================================== public function test_user_cannot_list_events_from_other_organisation(): void { Event::factory()->for($this->otherOrganisation)->count(3)->create(); $this->actingAs($this->orgAdmin) ->getJson("/api/v1/organisations/{$this->otherOrganisation->id}/events") ->assertStatus(403); } public function test_user_cannot_view_event_from_other_organisation(): void { $event = Event::factory()->for($this->otherOrganisation)->create(); $this->actingAs($this->orgAdmin) ->getJson("/api/v1/events/{$event->id}") ->assertStatus(403); } public function test_user_cannot_update_event_from_other_organisation(): void { $event = Event::factory()->for($this->otherOrganisation)->create(); $this->actingAs($this->orgAdmin) ->putJson("/api/v1/events/{$event->id}", ['name' => 'Hacked']) ->assertStatus(403); $this->assertDatabaseMissing('events', ['name' => 'Hacked']); } // ========================================== // INDEX (200) // ========================================== public function test_org_admin_can_list_events(): void { Event::factory()->for($this->organisation)->count(3)->create(); Event::factory()->for($this->otherOrganisation)->count(2)->create(); // should not appear $response = $this->actingAs($this->orgAdmin) ->getJson("/api/v1/organisations/{$this->organisation->id}/events"); $response->assertStatus(200) ->assertJsonCount(3, 'data') ->assertJsonStructure([ 'data' => ['*' => ['id', 'name', 'slug', 'start_date', 'end_date', 'status']], 'meta', ]); } // ========================================== // SHOW (200) // ========================================== public function test_can_view_own_organisation_event(): void { $event = Event::factory()->for($this->organisation)->create([ 'name' => 'Festivalpark 2026', ]); $response = $this->actingAs($this->orgMember) ->getJson("/api/v1/events/{$event->id}"); $response->assertStatus(200) ->assertJsonPath('data.name', 'Festivalpark 2026') ->assertJsonPath('data.organisation_id', $this->organisation->id); } // ========================================== // STORE (201) // ========================================== public function test_org_admin_can_create_event(): void { $eventData = [ 'name' => 'Zomerfestival 2026', 'slug' => 'zomerfestival-2026', 'start_date' => '2026-07-15', 'end_date' => '2026-07-17', 'timezone' => 'Europe/Amsterdam', ]; $response = $this->actingAs($this->orgAdmin) ->postJson("/api/v1/organisations/{$this->organisation->id}/events", $eventData); $response->assertStatus(201) ->assertJsonPath('data.name', 'Zomerfestival 2026'); $this->assertDatabaseHas('events', [ 'name' => 'Zomerfestival 2026', 'organisation_id' => $this->organisation->id, ]); } public function test_org_member_cannot_create_event(): void { $this->actingAs($this->orgMember) ->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ 'name' => 'Unauthorized Event', 'slug' => 'unauthorized', 'start_date' => '2026-07-15', 'end_date' => '2026-07-17', ]) ->assertStatus(403); } // ========================================== // VALIDATION (422) // ========================================== public function test_validation_errors_for_missing_required_fields(): void { $this->actingAs($this->orgAdmin) ->postJson("/api/v1/organisations/{$this->organisation->id}/events", []) ->assertStatus(422) ->assertJsonValidationErrors(['name', 'start_date', 'end_date']); } public function test_end_date_must_be_after_start_date(): void { $this->actingAs($this->orgAdmin) ->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ 'name' => 'Invalid Event', 'slug' => 'invalid-event', 'start_date' => '2026-07-20', 'end_date' => '2026-07-15', // before start ]) ->assertStatus(422) ->assertJsonValidationErrors(['end_date']); } // ========================================== // UPDATE (200) // ========================================== public function test_org_admin_can_update_event(): void { $event = Event::factory()->for($this->organisation)->create(); $this->actingAs($this->orgAdmin) ->putJson("/api/v1/events/{$event->id}", ['name' => 'Updated Name']) ->assertStatus(200) ->assertJsonPath('data.name', 'Updated Name'); $this->assertDatabaseHas('events', [ 'id' => $event->id, 'name' => 'Updated Name', ]); } // ========================================== // DELETE (204) // ========================================== public function test_org_admin_can_delete_event(): void { $event = Event::factory()->for($this->organisation)->create(); $this->actingAs($this->orgAdmin) ->deleteJson("/api/v1/events/{$event->id}") ->assertStatus(204); $this->assertSoftDeleted('events', ['id' => $event->id]); } public function test_org_member_cannot_delete_event(): void { $event = Event::factory()->for($this->organisation)->create(); $this->actingAs($this->orgMember) ->deleteJson("/api/v1/events/{$event->id}") ->assertStatus(403); $this->assertDatabaseHas('events', ['id' => $event->id]); } } ``` ### Model Factory Template ```php */ class EventFactory extends Factory { protected $model = Event::class; public function definition(): array { $startDate = fake('nl_NL')->dateTimeBetween('+1 week', '+6 months'); $endDate = (clone $startDate)->modify('+' . fake()->numberBetween(1, 5) . ' days'); return [ 'organisation_id' => Organisation::factory(), 'name' => fake('nl_NL')->words(3, true) . ' Festival', 'slug' => fake()->slug(), 'start_date' => $startDate, 'end_date' => $endDate, 'timezone' => 'Europe/Amsterdam', 'status' => fake()->randomElement(EventStatus::cases()), ]; } public function draft(): static { return $this->state(fn () => ['status' => EventStatus::Draft]); } public function published(): static { return $this->state(fn () => ['status' => EventStatus::Published]); } public function showday(): static { return $this->state(fn () => [ 'status' => EventStatus::ShowDay, 'start_date' => now(), 'end_date' => now()->addDays(2), ]); } } ``` ### Organisation Factory ```php */ class OrganisationFactory extends Factory { protected $model = Organisation::class; public function definition(): array { return [ 'name' => fake('nl_NL')->company(), 'slug' => fake()->unique()->slug(), 'billing_status' => 'active', 'settings' => [], ]; } } ``` ### TestCase Base Class ```php 'org_admin']); $organisation ??= Organisation::factory()->create(); $user = User::factory()->create(); $user->assignRole('org_admin'); $organisation->users()->attach($user); return $user; } /** * Create user with org_member role attached to an organisation. */ protected function createOrgMember(?Organisation $organisation = null): User { Role::firstOrCreate(['name' => 'org_member']); $organisation ??= Organisation::factory()->create(); $user = User::factory()->create(); $user->assignRole('org_member'); $organisation->users()->attach($user); return $user; } } ``` ## Running Tests ```bash # All tests cd api && php artisan test # Specific test class php artisan test --filter=EventTest # Specific test method php artisan test --filter=test_org_admin_can_create_event # With coverage php artisan test --coverage # After each module php artisan test --filter=ModuleName ``` ## Vue Testing (Vitest) ### Configuration ```typescript // vitest.config.ts import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { fileURLToPath } from 'node:url' export default defineConfig({ plugins: [vue()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, }) ``` ### Composable Test Pattern ```typescript // src/composables/__tests__/useEvents.test.ts import { describe, it, expect, beforeEach } from 'vitest' import { flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query' import { createApp } from 'vue' describe('useEvents', () => { beforeEach(() => { setActivePinia(createPinia()) }) it('fetches events for organisation', async () => { const app = createApp({ template: '
' }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }) app.use(VueQueryPlugin, { queryClient }) await flushPromises() // Assert API was called with correct organisation_id }) }) ``` ## Test Coverage Goals | Area | Target | Priority | |------|--------|----------| | API Endpoints | 90%+ | High | | Organisation Isolation | 100% | Critical | | Services | 100% | High | | Models | 80%+ | Medium | | Vue Composables | 80%+ | Medium | | Vue Components | 60%+ | Low | ## Testing Checklist (per module) ### Backend - [ ] Index: returns only own organisation's data (200) - [ ] Show: can view own org resource (200) - [ ] Show: cannot view other org resource (403) - [ ] Store: org_admin can create (201) - [ ] Store: org_member cannot create (403) - [ ] Store: validation errors returned (422) - [ ] Store: unauthenticated rejected (401) - [ ] Update: org_admin can update (200) - [ ] Update: cannot update other org resource (403) - [ ] Delete: org_admin can delete (204) - [ ] Delete: org_member cannot delete (403) - [ ] Soft delete: record still exists with deleted_at ### Frontend - [ ] Loading state shown during fetch - [ ] Error state with retry button on failure - [ ] Empty state when no data - [ ] Mobile responsive (375px) ## Best Practices ### Do - Test organisation isolation for every endpoint - Use Spatie roles in test setup (assignRole, hasRole) - Use factories with realistic Dutch test data (`fake('nl_NL')`) - Test soft deletes with `assertSoftDeleted` - One assertion focus per test - Use RefreshDatabase trait ### Don't - Skip organisation isolation tests - Share state between tests - Use real external APIs (Zender, SendGrid) - Test Laravel/Vue framework internals - Over-mock (test real behavior where possible)