feat: initial commit - Band Management application

This commit is contained in:
2026-01-06 03:11:46 +01:00
commit 34e12e00b3
24543 changed files with 3991790 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
---
description: Core workspace rules for Laravel + Vue/TypeScript full-stack application
globs: ["**/*"]
alwaysApply: true
---
# Workspace Rules
You are an expert full-stack developer working on a Laravel API backend with a Vue 3/TypeScript frontend using Vuexy admin template. This is an API-first architecture where the backend and frontend are completely separated.
## Tech Stack
### Backend (Laravel)
- PHP 8.3+
- Laravel 12+
- Laravel Sanctum for SPA authentication (token-based)
- MySQL 8.0 database
- Redis for cache and queues
- Pest for testing
### Frontend (Vue)
- Vue 3 with TypeScript (strict mode)
- Vite as build tool
- Vuexy Admin Template
- TanStack Query (Vue Query) for server state
- Pinia for client state
- Vue Router for routing
- Axios for HTTP client
## Project Structure
```
band-management/
├── api/ # Laravel 12 API
│ ├── app/
│ │ ├── Actions/ # Single-purpose business logic
│ │ ├── Enums/ # PHP enums
│ │ ├── Http/
│ │ │ ├── Controllers/Api/V1/
│ │ │ ├── Middleware/
│ │ │ ├── Requests/ # Form Request validation
│ │ │ └── Resources/ # API Resources
│ │ ├── Models/ # Eloquent models
│ │ ├── Policies/ # Authorization
│ │ ├── Services/ # Complex business logic
│ │ └── Traits/ # Shared traits
│ ├── database/
│ │ ├── factories/
│ │ ├── migrations/
│ │ └── seeders/
│ ├── routes/
│ │ └── api.php # API routes
│ └── tests/
│ ├── Feature/Api/
│ └── Unit/
├── apps/
│ ├── admin/ # Admin Dashboard (Vuexy full)
│ │ ├── src/
│ │ │ ├── @core/ # Vuexy core (don't modify)
│ │ │ ├── @layouts/ # Vuexy layouts (don't modify)
│ │ │ ├── components/ # Custom components
│ │ │ ├── composables/ # Vue composables
│ │ │ ├── layouts/ # App layouts
│ │ │ ├── lib/ # Utilities (api-client, etc.)
│ │ │ ├── navigation/ # Menu configuration
│ │ │ ├── pages/ # Page components
│ │ │ ├── plugins/ # Vue plugins
│ │ │ ├── router/ # Vue Router
│ │ │ ├── stores/ # Pinia stores
│ │ │ └── types/ # TypeScript types
│ │ └── ...
│ │
│ ├── band/ # Band Portal (Vuexy starter)
│ └── customers/ # Customer Portal (Vuexy starter)
├── docker/ # Docker configurations
├── docs/ # Documentation
└── .cursor/ # Cursor AI configuration
```
## Naming Conventions
### PHP (Laravel)
| Type | Convention | Example |
|------|------------|---------|
| Models | Singular PascalCase | `Event`, `MusicNumber` |
| Controllers | PascalCase + Controller | `EventController` |
| Form Requests | Action + Resource + Request | `StoreEventRequest` |
| Resources | Resource + Resource | `EventResource` |
| Actions | Verb + Resource + Action | `CreateEventAction` |
| Migrations | snake_case with timestamp | `create_events_table` |
| Tables | Plural snake_case | `events`, `music_numbers` |
| Columns | snake_case | `event_date`, `created_at` |
| Enums | Singular PascalCase | `EventStatus` |
### TypeScript (Vue)
| Type | Convention | Example |
|------|------------|---------|
| Components | PascalCase | `EventCard.vue` |
| Pages | PascalCase + Page | `EventsPage.vue` |
| Composables | camelCase with "use" | `useEvents.ts` |
| Stores | camelCase | `authStore.ts` |
| Types/Interfaces | PascalCase | `Event`, `ApiResponse` |
| Files | kebab-case or camelCase | `api-client.ts` |
## Code Style
### General Principles
1. **Explicit over implicit** - Be clear about types, returns, and intentions
2. **Small, focused units** - Each file/function does one thing well
3. **Consistent formatting** - Use automated formatters
4. **Descriptive names** - Names should explain purpose
5. **No magic** - Avoid hidden behavior
### PHP
- Use `declare(strict_types=1);` in all files
- Use `final` for classes that shouldn't be extended
- Use readonly properties where applicable
- Prefer named arguments for clarity
- Use enums instead of string constants
### TypeScript
- Enable strict mode in tsconfig
- No `any` types - use `unknown` if truly unknown
- Use interface for objects, type for unions/primitives
- Prefer `const` over `let`
- Use optional chaining and nullish coalescing
## Environment Configuration
### Development URLs
| Service | URL |
|---------|-----|
| API | http://localhost:8000/api/v1 |
| Admin SPA | http://localhost:5173 |
| Band SPA | http://localhost:5174 |
| Customer SPA | http://localhost:5175 |
| MySQL | localhost:3306 |
| Redis | localhost:6379 |
| Mailpit | http://localhost:8025 |
### Database Credentials (Development)
```
Host: 127.0.0.1
Port: 3306
Database: band_management
Username: band_management
Password: secret
```
### Production URLs
| Service | URL |
|---------|-----|
| API | https://api.bandmanagement.nl |
| Admin | https://admin.bandmanagement.nl |
| Band | https://band.bandmanagement.nl |
| Customers | https://customers.bandmanagement.nl |
## Git Conventions
### Branch Names
- `feature/event-management`
- `fix/rsvp-validation`
- `refactor/auth-system`
### Commit Messages
```
feat: add event RSVP functionality
fix: correct date validation in events
refactor: extract event creation to action class
docs: update API documentation
test: add event controller tests
```
## Dependencies
### PHP (api/composer.json)
- PHP 8.3+
- Laravel 12
- Laravel Sanctum
- Laravel Pint (formatting)
- Pest PHP (testing)
### Node (apps/*/package.json)
- Vue 3.4+
- TypeScript 5.3+
- Vite 5+
- Pinia
- @tanstack/vue-query
- axios
## Code Style Principles
1. **Readability over cleverness** - Write code that is easy to understand
2. **Single Responsibility** - Each class/function does one thing well
3. **Type Safety** - Leverage TypeScript and PHP type hints everywhere
4. **Testability** - Write code that is easy to test
5. **API Consistency** - Follow RESTful conventions
## Response Format
When generating code:
1. Always include proper type hints/annotations
2. Add brief comments for complex logic only
3. Follow the established patterns in the codebase
4. Consider error handling and edge cases
5. Suggest tests for new functionality
## Communication Style
- Be concise and direct
- Provide working code examples
- Explain architectural decisions briefly
- Ask clarifying questions only when truly ambiguous

View File

@@ -0,0 +1,786 @@
---
description: Laravel API development guidelines
globs: ["api/**/*.php"]
alwaysApply: true
---
# Laravel API Development Rules
## PHP Conventions
- Use PHP 8.3+ features: constructor property promotion, readonly properties, match expressions
- Use `match` operator over `switch` wherever possible
- Import all classes with `use` statements; avoid fully-qualified class names inline
- Use named arguments for functions with 3+ parameters
- Prefer early returns over nested conditionals
```php
// ✅ Good - constructor property promotion
public function __construct(
private readonly UserRepository $users,
private readonly Mailer $mailer,
) {}
// ✅ Good - early return
public function handle(Request $request): Response
{
if (!$request->user()) {
return response()->json(['error' => 'Unauthorized'], 401);
}
// Main logic here
}
// ❌ Avoid - nested conditionals
public function handle(Request $request): Response
{
if ($request->user()) {
// Nested logic
} else {
return response()->json(['error' => 'Unauthorized'], 401);
}
}
```
## Core Principles
1. **API-only** - No Blade views, no web routes
2. **Thin controllers** - Business logic in Actions
3. **Consistent responses** - Use API Resources and response trait
4. **Validate everything** - Use Form Requests
5. **Authorize properly** - Use Policies
6. **Test thoroughly** - Feature tests for all endpoints
## File Templates
### Model
```php
<?php
declare(strict_types=1);
namespace App\Models;
use App\Enums\EventStatus;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Event extends Model
{
use HasFactory;
use HasUlids;
protected $fillable = [
'title',
'description',
'location_id',
'customer_id',
'setlist_id',
'event_date',
'start_time',
'end_time',
'fee',
'currency',
'status',
'visibility',
'rsvp_deadline',
'notes',
'internal_notes',
'created_by',
];
protected $casts = [
'event_date' => 'date',
'start_time' => 'datetime:H:i',
'end_time' => 'datetime:H:i',
'fee' => 'decimal:2',
'status' => EventStatus::class,
'rsvp_deadline' => 'datetime',
];
// Relationships
public function location(): BelongsTo
{
return $this->belongsTo(Location::class);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function setlist(): BelongsTo
{
return $this->belongsTo(Setlist::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function invitations(): HasMany
{
return $this->hasMany(EventInvitation::class);
}
// Scopes
public function scopeUpcoming($query)
{
return $query->where('event_date', '>=', now()->toDateString())
->orderBy('event_date');
}
public function scopeConfirmed($query)
{
return $query->where('status', EventStatus::Confirmed);
}
}
```
### Enum
```php
<?php
declare(strict_types=1);
namespace App\Enums;
enum EventStatus: string
{
case Draft = 'draft';
case Pending = 'pending';
case Confirmed = 'confirmed';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::Pending => 'Pending Confirmation',
self::Confirmed => 'Confirmed',
self::Completed => 'Completed',
self::Cancelled => 'Cancelled',
};
}
public function color(): string
{
return match ($this) {
self::Draft => 'gray',
self::Pending => 'yellow',
self::Confirmed => 'green',
self::Completed => 'blue',
self::Cancelled => 'red',
};
}
}
```
### Migration
```php
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->string('title');
$table->text('description')->nullable();
$table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete();
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
$table->foreignUlid('setlist_id')->nullable()->constrained()->nullOnDelete();
$table->date('event_date');
$table->time('start_time');
$table->time('end_time')->nullable();
$table->time('load_in_time')->nullable();
$table->time('soundcheck_time')->nullable();
$table->decimal('fee', 10, 2)->nullable();
$table->string('currency', 3)->default('EUR');
$table->enum('status', ['draft', 'pending', 'confirmed', 'completed', 'cancelled'])->default('draft');
$table->enum('visibility', ['private', 'members', 'public'])->default('members');
$table->dateTime('rsvp_deadline')->nullable();
$table->text('notes')->nullable();
$table->text('internal_notes')->nullable();
$table->boolean('is_public_setlist')->default(false);
$table->foreignUlid('created_by')->constrained('users');
$table->timestamps();
$table->index(['event_date', 'status']);
});
}
public function down(): void
{
Schema::dropIfExists('events');
}
};
```
### Controller
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Events\CreateEventAction;
use App\Actions\Events\UpdateEventAction;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\StoreEventRequest;
use App\Http\Requests\Api\V1\UpdateEventRequest;
use App\Http\Resources\Api\V1\EventCollection;
use App\Http\Resources\Api\V1\EventResource;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
final class EventController extends Controller
{
public function index(): EventCollection
{
$events = Event::query()
->with(['location', 'customer'])
->latest('event_date')
->paginate();
return new EventCollection($events);
}
public function store(StoreEventRequest $request, CreateEventAction $action): JsonResponse
{
$event = $action->execute($request->validated());
return $this->created(
new EventResource($event->load(['location', 'customer'])),
'Event created successfully'
);
}
public function show(Event $event): EventResource
{
return new EventResource(
$event->load(['location', 'customer', 'setlist', 'invitations.user'])
);
}
public function update(UpdateEventRequest $request, Event $event, UpdateEventAction $action): JsonResponse
{
$event = $action->execute($event, $request->validated());
return $this->success(
new EventResource($event),
'Event updated successfully'
);
}
public function destroy(Event $event): JsonResponse
{
$event->delete();
return $this->success(null, 'Event deleted successfully');
}
}
```
### Form Request
```php
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\EventStatus;
use App\Enums\EventVisibility;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreEventRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or use policy
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
'event_date' => ['required', 'date', 'after_or_equal:today'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'],
'load_in_time' => ['nullable', 'date_format:H:i'],
'soundcheck_time' => ['nullable', 'date_format:H:i'],
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
'currency' => ['sometimes', 'string', 'size:3'],
'status' => ['sometimes', Rule::enum(EventStatus::class)],
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
'rsvp_deadline' => ['nullable', 'date', 'before:event_date'],
'notes' => ['nullable', 'string', 'max:5000'],
'internal_notes' => ['nullable', 'string', 'max:5000'],
];
}
public function messages(): array
{
return [
'event_date.after_or_equal' => 'The event date must be today or a future date.',
'end_time.after' => 'The end time must be after the start time.',
];
}
}
```
### API Resource
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'event_date' => $this->event_date->toDateString(),
'start_time' => $this->start_time?->format('H:i'),
'end_time' => $this->end_time?->format('H:i'),
'load_in_time' => $this->load_in_time?->format('H:i'),
'soundcheck_time' => $this->soundcheck_time?->format('H:i'),
'fee' => $this->fee,
'currency' => $this->currency,
'status' => $this->status->value,
'status_label' => $this->status->label(),
'visibility' => $this->visibility,
'rsvp_deadline' => $this->rsvp_deadline?->toIso8601String(),
'notes' => $this->notes,
'internal_notes' => $this->when(
$request->user()?->isAdmin(),
$this->internal_notes
),
'location' => new LocationResource($this->whenLoaded('location')),
'customer' => new CustomerResource($this->whenLoaded('customer')),
'setlist' => new SetlistResource($this->whenLoaded('setlist')),
'invitations' => EventInvitationResource::collection(
$this->whenLoaded('invitations')
),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
```
### Resource Collection
```php
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
final class EventCollection extends ResourceCollection
{
public $collects = EventResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
public function with(Request $request): array
{
return [
'success' => true,
'meta' => [
'pagination' => [
'current_page' => $this->currentPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'last_page' => $this->lastPage(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
],
],
];
}
}
```
### Action Class
```php
<?php
declare(strict_types=1);
namespace App\Actions\Events;
use App\Models\Event;
use Illuminate\Support\Facades\Auth;
final class CreateEventAction
{
public function execute(array $data): Event
{
$data['created_by'] = Auth::id();
return Event::create($data);
}
}
```
### API Response Trait
```php
<?php
declare(strict_types=1);
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponse
{
protected function success(mixed $data = null, string $message = 'Success', int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'data' => $data,
'message' => $message,
], $code);
}
protected function created(mixed $data = null, string $message = 'Created'): JsonResponse
{
return $this->success($data, $message, 201);
}
protected function error(string $message, int $code = 400, array $errors = []): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
protected function notFound(string $message = 'Resource not found'): JsonResponse
{
return $this->error($message, 404);
}
protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
{
return $this->error($message, 401);
}
protected function forbidden(string $message = 'Forbidden'): JsonResponse
{
return $this->error($message, 403);
}
}
```
### Base Controller
```php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Traits\ApiResponse;
abstract class Controller
{
use ApiResponse;
}
```
### Policy
```php
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Event;
use App\Models\User;
final class EventPolicy
{
public function viewAny(User $user): bool
{
return true;
}
public function view(User $user, Event $event): bool
{
return true;
}
public function create(User $user): bool
{
return $user->isAdmin() || $user->isBookingAgent();
}
public function update(User $user, Event $event): bool
{
return $user->isAdmin() || $user->isBookingAgent();
}
public function delete(User $user, Event $event): bool
{
return $user->isAdmin();
}
}
```
### Routes (api.php)
```php
<?php
declare(strict_types=1);
use App\Http\Controllers\Api\V1\AuthController;
use App\Http\Controllers\Api\V1\EventController;
use App\Http\Controllers\Api\V1\LocationController;
use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\MusicController;
use App\Http\Controllers\Api\V1\SetlistController;
use App\Http\Controllers\Api\V1\CustomerController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () {
// Public routes
Route::post('auth/login', [AuthController::class, 'login']);
Route::post('auth/register', [AuthController::class, 'register']);
Route::post('auth/forgot-password', [AuthController::class, 'forgotPassword']);
Route::post('auth/reset-password', [AuthController::class, 'resetPassword']);
// Protected routes
Route::middleware('auth:sanctum')->group(function () {
// Auth
Route::get('auth/user', [AuthController::class, 'user']);
Route::post('auth/logout', [AuthController::class, 'logout']);
// Resources
Route::apiResource('events', EventController::class);
Route::post('events/{event}/invite', [EventController::class, 'invite']);
Route::post('events/{event}/rsvp', [EventController::class, 'rsvp']);
Route::apiResource('members', MemberController::class);
Route::apiResource('music', MusicController::class);
Route::apiResource('setlists', SetlistController::class);
Route::apiResource('locations', LocationController::class);
Route::apiResource('customers', CustomerController::class);
});
});
```
## Best Practices
### Always Use
- `declare(strict_types=1)` at the top of every file
- `final` keyword for Action classes, Form Requests, Resources
- Type hints for all parameters and return types
- Named arguments for better readability
- Enums for status fields and fixed options
- ULIDs for all primary keys
- Eager loading to prevent N+1 queries
- API Resources for all responses
### Avoid
- Business logic in controllers
- String constants (use enums)
- Auto-increment IDs
- Direct model creation in controllers
- Returning raw models (use Resources)
- Hardcoded strings for error messages
## DTOs (Data Transfer Objects)
Use DTOs for complex data passing between layers:
```php
<?php
declare(strict_types=1);
namespace App\DTOs;
readonly class CreateEventDTO
{
public function __construct(
public string $title,
public string $eventDate,
public string $startTime,
public ?string $description = null,
public ?string $locationId = null,
public ?string $customerId = null,
public ?string $endTime = null,
public ?float $fee = null,
) {}
public static function from(array $data): self
{
return new self(
title: $data['title'],
eventDate: $data['event_date'],
startTime: $data['start_time'],
description: $data['description'] ?? null,
locationId: $data['location_id'] ?? null,
customerId: $data['customer_id'] ?? null,
endTime: $data['end_time'] ?? null,
fee: $data['fee'] ?? null,
);
}
}
// Usage
$dto = CreateEventDTO::from($request->validated());
$event = $action->execute($dto);
```
## Helpers
Use Laravel helpers instead of facades:
```php
// ✅ Good
auth()->id()
auth()->user()
now()
str($string)->slug()
collect($array)->filter()
cache()->remember('key', 3600, fn() => $value)
// ❌ Avoid
Auth::id()
Carbon::now()
Str::slug($string)
Cache::remember(...)
```
## Error Handling
Create domain-specific exceptions:
```php
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class EventNotFoundException extends Exception
{
public function render(): JsonResponse
{
return response()->json([
'success' => false,
'message' => 'Event not found',
], 404);
}
}
class EventAlreadyConfirmedException extends Exception
{
public function render(): JsonResponse
{
return response()->json([
'success' => false,
'message' => 'Event has already been confirmed and cannot be modified',
], 422);
}
}
// Usage in Action
if ($event->isConfirmed()) {
throw new EventAlreadyConfirmedException();
}
```
## Query Scopes
Add reusable query scopes to models:
```php
// In Event model
public function scopeUpcoming(Builder $query): Builder
{
return $query->where('event_date', '>=', now()->toDateString())
->orderBy('event_date');
}
public function scopeForUser(Builder $query, User $user): Builder
{
return $query->whereHas('invitations', fn ($q) =>
$q->where('user_id', $user->id)
);
}
public function scopeConfirmed(Builder $query): Builder
{
return $query->where('status', EventStatus::Confirmed);
}
// Usage
Event::upcoming()->confirmed()->get();
Event::forUser($user)->upcoming()->get();
```

1056
.cursor/rules/101_vue.mdc Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,870 @@
---
description: Testing standards for Laravel API and Vue frontend
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. **Unit tests for logic** - Test Actions and complex functions
4. **Integration tests for Vue** - Test component interactions
5. **Fast and isolated** - Each test should be independent
## Laravel Testing
### Directory Structure
```
api/tests/
├── Feature/
│ └── Api/
│ ├── AuthTest.php
│ ├── EventTest.php
│ ├── MemberTest.php
│ ├── MusicTest.php
│ ├── SetlistTest.php
│ ├── LocationTest.php
│ └── CustomerTest.php
├── Unit/
│ ├── Actions/
│ │ ├── CreateEventActionTest.php
│ │ └── ...
│ └── Models/
│ ├── EventTest.php
│ └── ...
└── TestCase.php
```
### Feature Test Template
```php
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\EventStatus;
use App\Models\Event;
use App\Models\Location;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class EventTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private User $member;
protected function setUp(): void
{
parent::setUp();
$this->admin = User::factory()->admin()->create();
$this->member = User::factory()->member()->create();
}
// ==========================================
// INDEX
// ==========================================
public function test_guests_cannot_list_events(): void
{
$response = $this->getJson('/api/v1/events');
$response->assertStatus(401);
}
public function test_authenticated_users_can_list_events(): void
{
Event::factory()->count(3)->create();
$response = $this->actingAs($this->member)
->getJson('/api/v1/events');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'*' => ['id', 'title', 'event_date', 'status'],
],
'meta' => ['pagination'],
])
->assertJsonCount(3, 'data');
}
public function test_events_are_paginated(): void
{
Event::factory()->count(20)->create();
$response = $this->actingAs($this->member)
->getJson('/api/v1/events?per_page=10');
$response->assertStatus(200)
->assertJsonCount(10, 'data')
->assertJsonPath('meta.pagination.total', 20)
->assertJsonPath('meta.pagination.per_page', 10);
}
// ==========================================
// SHOW
// ==========================================
public function test_can_view_single_event(): void
{
$event = Event::factory()->create(['title' => 'Summer Concert']);
$response = $this->actingAs($this->member)
->getJson("/api/v1/events/{$event->id}");
$response->assertStatus(200)
->assertJsonPath('success', true)
->assertJsonPath('data.title', 'Summer Concert');
}
public function test_returns_404_for_nonexistent_event(): void
{
$response = $this->actingAs($this->member)
->getJson('/api/v1/events/nonexistent-id');
$response->assertStatus(404);
}
// ==========================================
// STORE
// ==========================================
public function test_admin_can_create_event(): void
{
$location = Location::factory()->create();
$eventData = [
'title' => 'New Year Concert',
'event_date' => now()->addMonth()->toDateString(),
'start_time' => '20:00',
'location_id' => $location->id,
'status' => 'draft',
];
$response = $this->actingAs($this->admin)
->postJson('/api/v1/events', $eventData);
$response->assertStatus(201)
->assertJsonPath('success', true)
->assertJsonPath('data.title', 'New Year Concert');
$this->assertDatabaseHas('events', [
'title' => 'New Year Concert',
'location_id' => $location->id,
]);
}
public function test_member_cannot_create_event(): void
{
$eventData = [
'title' => 'Unauthorized Event',
'event_date' => now()->addMonth()->toDateString(),
'start_time' => '20:00',
];
$response = $this->actingAs($this->member)
->postJson('/api/v1/events', $eventData);
$response->assertStatus(403);
}
public function test_validation_errors_are_returned(): void
{
$response = $this->actingAs($this->admin)
->postJson('/api/v1/events', []);
$response->assertStatus(422)
->assertJsonPath('success', false)
->assertJsonValidationErrors(['title', 'event_date', 'start_time']);
}
public function test_event_date_must_be_future(): void
{
$response = $this->actingAs($this->admin)
->postJson('/api/v1/events', [
'title' => 'Past Event',
'event_date' => now()->subDay()->toDateString(),
'start_time' => '20:00',
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['event_date']);
}
// ==========================================
// UPDATE
// ==========================================
public function test_admin_can_update_event(): void
{
$event = Event::factory()->create(['title' => 'Old Title']);
$response = $this->actingAs($this->admin)
->putJson("/api/v1/events/{$event->id}", [
'title' => 'Updated Title',
]);
$response->assertStatus(200)
->assertJsonPath('data.title', 'Updated Title');
$this->assertDatabaseHas('events', [
'id' => $event->id,
'title' => 'Updated Title',
]);
}
public function test_can_update_event_status(): void
{
$event = Event::factory()->draft()->create();
$response = $this->actingAs($this->admin)
->putJson("/api/v1/events/{$event->id}", [
'status' => 'confirmed',
]);
$response->assertStatus(200)
->assertJsonPath('data.status', 'confirmed');
}
// ==========================================
// DELETE
// ==========================================
public function test_admin_can_delete_event(): void
{
$event = Event::factory()->create();
$response = $this->actingAs($this->admin)
->deleteJson("/api/v1/events/{$event->id}");
$response->assertStatus(200)
->assertJsonPath('success', true);
$this->assertDatabaseMissing('events', ['id' => $event->id]);
}
public function test_member_cannot_delete_event(): void
{
$event = Event::factory()->create();
$response = $this->actingAs($this->member)
->deleteJson("/api/v1/events/{$event->id}");
$response->assertStatus(403);
$this->assertDatabaseHas('events', ['id' => $event->id]);
}
// ==========================================
// RELATIONSHIPS
// ==========================================
public function test_event_includes_location_when_loaded(): void
{
$location = Location::factory()->create(['name' => 'City Hall']);
$event = Event::factory()->create(['location_id' => $location->id]);
$response = $this->actingAs($this->member)
->getJson("/api/v1/events/{$event->id}");
$response->assertStatus(200)
->assertJsonPath('data.location.name', 'City Hall');
}
}
```
### Unit Test Template (Action)
```php
<?php
declare(strict_types=1);
namespace Tests\Unit\Actions;
use App\Actions\Events\CreateEventAction;
use App\Models\Event;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class CreateEventActionTest extends TestCase
{
use RefreshDatabase;
private CreateEventAction $action;
protected function setUp(): void
{
parent::setUp();
$this->action = new CreateEventAction();
}
public function test_creates_event_with_valid_data(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$data = [
'title' => 'Test Event',
'event_date' => now()->addMonth()->toDateString(),
'start_time' => '20:00',
];
$event = $this->action->execute($data);
$this->assertInstanceOf(Event::class, $event);
$this->assertEquals('Test Event', $event->title);
$this->assertEquals($user->id, $event->created_by);
}
public function test_sets_default_values(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$event = $this->action->execute([
'title' => 'Test',
'event_date' => now()->addMonth()->toDateString(),
'start_time' => '20:00',
]);
$this->assertEquals('EUR', $event->currency);
$this->assertEquals('draft', $event->status->value);
}
}
```
### Model Factory Template
```php
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Enums\EventStatus;
use App\Enums\EventVisibility;
use App\Models\Event;
use App\Models\Location;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Event>
*/
final class EventFactory extends Factory
{
protected $model = Event::class;
public function definition(): array
{
return [
'title' => fake()->sentence(3),
'description' => fake()->optional()->paragraph(),
'event_date' => fake()->dateTimeBetween('+1 week', '+6 months'),
'start_time' => fake()->time('H:i'),
'end_time' => fake()->optional()->time('H:i'),
'fee' => fake()->optional()->randomFloat(2, 100, 5000),
'currency' => 'EUR',
'status' => fake()->randomElement(EventStatus::cases()),
'visibility' => EventVisibility::Members,
'created_by' => User::factory(),
];
}
public function draft(): static
{
return $this->state(fn () => ['status' => EventStatus::Draft]);
}
public function confirmed(): static
{
return $this->state(fn () => ['status' => EventStatus::Confirmed]);
}
public function withLocation(): static
{
return $this->state(fn () => ['location_id' => Location::factory()]);
}
public function upcoming(): static
{
return $this->state(fn () => [
'event_date' => fake()->dateTimeBetween('+1 day', '+1 month'),
'status' => EventStatus::Confirmed,
]);
}
public function past(): static
{
return $this->state(fn () => [
'event_date' => fake()->dateTimeBetween('-6 months', '-1 day'),
'status' => EventStatus::Completed,
]);
}
}
```
### User Factory States
```php
<?php
// In UserFactory.php
public function admin(): static
{
return $this->state(fn () => [
'type' => 'member',
'role' => 'admin',
'status' => 'active',
]);
}
public function member(): static
{
return $this->state(fn () => [
'type' => 'member',
'role' => 'member',
'status' => 'active',
]);
}
public function bookingAgent(): static
{
return $this->state(fn () => [
'type' => 'member',
'role' => 'booking_agent',
'status' => 'active',
]);
}
public function customer(): static
{
return $this->state(fn () => [
'type' => 'customer',
'role' => null,
'status' => 'active',
]);
}
```
### Test Helpers (TestCase.php)
```php
<?php
declare(strict_types=1);
namespace Tests;
use App\Models\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
/**
* Create and authenticate as admin user.
*/
protected function actingAsAdmin(): User
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
return $admin;
}
/**
* Create and authenticate as regular member.
*/
protected function actingAsMember(): User
{
$member = User::factory()->member()->create();
$this->actingAs($member);
return $member;
}
/**
* Assert API success response structure.
*/
protected function assertApiSuccess($response, int $status = 200): void
{
$response->assertStatus($status)
->assertJsonStructure(['success', 'data'])
->assertJsonPath('success', true);
}
/**
* Assert API error response structure.
*/
protected function assertApiError($response, int $status = 400): void
{
$response->assertStatus($status)
->assertJsonPath('success', false)
->assertJsonStructure(['success', 'message']);
}
}
```
## Running Tests
### Laravel
```bash
# Run all tests
cd api && php artisan test
# Run with coverage
php artisan test --coverage
# Run specific test file
php artisan test tests/Feature/Api/EventTest.php
# Run specific test method
php artisan test --filter test_admin_can_create_event
# Run in parallel
php artisan test --parallel
```
### Pest PHP Syntax
```php
<?php
use App\Models\Event;
use App\Models\User;
beforeEach(function () {
$this->admin = User::factory()->admin()->create();
});
it('allows admin to create events', function () {
$response = $this->actingAs($this->admin)
->postJson('/api/v1/events', [
'title' => 'Concert',
'event_date' => now()->addMonth()->toDateString(),
'start_time' => '20:00',
]);
$response->assertStatus(201);
expect(Event::count())->toBe(1);
});
it('validates required fields', function () {
$response = $this->actingAs($this->admin)
->postJson('/api/v1/events', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['title', 'event_date']);
});
it('returns paginated results', function () {
Event::factory()->count(25)->create();
$response = $this->actingAs($this->admin)
->getJson('/api/v1/events?per_page=10');
expect($response->json('data'))->toHaveCount(10);
expect($response->json('meta.pagination.total'))->toBe(25);
});
```
## Vue Testing (Vitest + Vue Test Utils)
### File Organization
```
src/
├── components/
│ └── EventCard/
│ ├── EventCard.vue
│ └── EventCard.test.ts
├── composables/
│ └── useEvents.test.ts
└── test/
├── setup.ts
├── mocks/
│ ├── handlers.ts # MSW handlers
│ └── server.ts
└── utils.ts # Custom render with providers
```
### 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)),
},
},
})
```
### Test Setup
```typescript
// src/test/setup.ts
import { vi, beforeAll, afterAll, afterEach } from 'vitest'
import { config } from '@vue/test-utils'
import { server } from './mocks/server'
// Start MSW server
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Global stubs for Vuexy components
config.global.stubs = {
VBtn: true,
VCard: true,
VCardText: true,
VCardTitle: true,
VTable: true,
VDialog: true,
VProgressCircular: true,
RouterLink: true,
}
```
### MSW Setup for API Mocking
```typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/v1/events', () => {
return HttpResponse.json({
success: true,
data: [
{ id: '1', title: 'Event 1', status: 'confirmed' },
{ id: '2', title: 'Event 2', status: 'draft' },
{ id: '3', title: 'Event 3', status: 'pending' },
],
meta: { pagination: { current_page: 1, total: 3, per_page: 15 } }
})
}),
http.post('/api/v1/events', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
success: true,
data: { id: '4', ...body },
message: 'Event created successfully'
}, { status: 201 })
}),
http.get('/api/v1/auth/user', () => {
return HttpResponse.json({
success: true,
data: { id: '1', name: 'Test User', email: 'test@example.com', role: 'admin' }
})
}),
]
// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
```
### Test Utilities
```typescript
// src/test/utils.ts
import { mount, VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { createRouter, createWebHistory } from 'vue-router'
import type { Component } from 'vue'
export function createTestingPinia() {
const pinia = createPinia()
setActivePinia(pinia)
return pinia
}
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
}
export function mountWithProviders(component: Component, options = {}) {
const pinia = createTestingPinia()
const queryClient = createTestQueryClient()
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div />' } }],
})
return mount(component, {
global: {
plugins: [pinia, router, [VueQueryPlugin, { queryClient }]],
stubs: {
VBtn: true,
VCard: true,
VTable: true,
},
},
...options,
})
}
```
### Component Test Pattern
```typescript
// src/components/EventCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import EventCard from './EventCard.vue'
describe('EventCard', () => {
const mockEvent = {
id: '1',
title: 'Summer Concert',
event_date: '2025-07-15',
status: 'confirmed',
status_label: 'Confirmed',
}
it('displays event title', () => {
const wrapper = mount(EventCard, {
props: { event: mockEvent },
})
expect(wrapper.text()).toContain('Summer Concert')
})
it('emits edit event when edit button clicked', async () => {
const wrapper = mount(EventCard, {
props: { event: mockEvent },
})
await wrapper.find('[data-test="edit-btn"]').trigger('click')
expect(wrapper.emitted('edit')).toBeTruthy()
expect(wrapper.emitted('edit')?.[0]).toEqual([mockEvent])
})
it('shows correct status color', () => {
const wrapper = mount(EventCard, {
props: { event: mockEvent },
})
const chip = wrapper.find('[data-test="status-chip"]')
expect(chip.classes()).toContain('bg-success')
})
})
```
### Composable Test Pattern
```typescript
// src/composables/__tests__/useEvents.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useEvents } from '../useEvents'
import { createTestQueryClient, createTestingPinia } from '@/test/utils'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { createApp } from 'vue'
describe('useEvents', () => {
beforeEach(() => {
createTestingPinia()
})
it('fetches events successfully', async () => {
const app = createApp({ template: '<div />' })
const queryClient = createTestQueryClient()
app.use(VueQueryPlugin, { queryClient })
// Mount and test...
await flushPromises()
// Assertions
})
})
```
### Pest Expectations (Laravel)
```php
// Use fluent expectations
expect($user)->toBeInstanceOf(User::class);
expect($collection)->toHaveCount(5);
expect($response->json('data'))->toMatchArray([...]);
// Chain expectations
expect($user)
->name->toBe('John')
->email->toContain('@')
->created_at->toBeInstanceOf(Carbon::class);
```
## Test Coverage Goals
| Area | Target | Priority |
|------|--------|----------|
| API Endpoints | 90%+ | High |
| Actions | 100% | High |
| Models | 80%+ | Medium |
| Vue Composables | 80%+ | Medium |
| Vue Components | 60%+ | Low |
**Minimum Coverage**: 80% line coverage
## Coverage Requirements
- **Feature tests**: All API endpoints must have tests
- **Unit tests**: All Actions and Services with business logic
- **Component tests**: All interactive components
- **Composable tests**: All custom composables that fetch data
## Best Practices
### Do
- Test happy path AND edge cases
- Use descriptive test names
- One assertion focus per test
- Use factories for test data
- Clean up after tests (RefreshDatabase)
- Test authorization separately
### Don't
- Test framework code (Laravel, Vue)
- Test private methods directly
- Share state between tests
- Use real external APIs
- Over-mock (test real behavior)