Files
band-management/.cursor/rules/200_testing.mdc
bert.hausmans 1cb7674d52 refactor: align codebase with EventCrew domain and trim legacy band stack
- Update API: events, users, policies, routes, resources, migrations
- Remove deprecated models/resources (customers, setlists, invitations, etc.)
- Refresh admin app and docs; remove apps/band

Made-with: Cursor
2026-03-29 23:19:06 +02:00

550 lines
15 KiB
Plaintext

---
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
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use App\Enums\EventStatus;
use App\Models\Event;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class EventTest extends TestCase
{
use RefreshDatabase;
private User $orgAdmin;
private User $orgMember;
private User $otherOrgUser;
private Organisation $organisation;
private Organisation $otherOrganisation;
protected function setUp(): void
{
parent::setUp();
// Seed Spatie roles
Role::create(['name' => '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
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\EventStatus;
use App\Models\Event;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Event>
*/
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
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Organisation;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Organisation>
*/
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
<?php
declare(strict_types=1);
namespace Tests;
use App\Models\Organisation;
use App\Models\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Spatie\Permission\Models\Role;
abstract class TestCase extends BaseTestCase
{
/**
* Create user with org_admin role attached to an organisation.
*/
protected function createOrgAdmin(?Organisation $organisation = null): User
{
Role::firstOrCreate(['name' => '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: '<div />' })
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)