- 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
550 lines
15 KiB
Plaintext
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)
|