Implement multi-tenancy support with OAuth2 authentication for tenant admins, Stripe integration for event purchases and credits ledger, new Filament resources for event purchases, updated API routes and middleware for tenant isolation and token guarding, added factories/seeders/migrations for new models (Tenant, EventPurchase, OAuth entities, etc.), enhanced tests, and documentation updates. Removed outdated DemoAchievementsSeeder.

This commit is contained in:
2025-09-17 19:56:54 +02:00
parent 5fbb9cb240
commit 42d6e98dff
84 changed files with 6125 additions and 155 deletions

View File

@@ -0,0 +1,23 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
class MockTenantMiddleware
{
public function handle(Request $request, Closure $next)
{
// Skip auth for tests and set mock tenant from app instance
$tenant = app('tenant');
if ($tenant) {
$request->attributes->set('tenant', $tenant);
$request->attributes->set('tenant_id', $tenant->id);
$request->merge(['tenant_id' => $tenant->id]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class SettingsApiTest extends TenantTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->mockTenantContext();
}
#[Test]
public function unauthenticated_users_cannot_access_settings()
{
$response = $this->getJson('/api/v1/tenant/settings');
$response->assertStatus(401);
}
#[Test]
public function authenticated_user_can_get_settings()
{
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings');
$response->assertStatus(200)
->assertJson(['message' => 'Settings erfolgreich abgerufen.'])
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
}
#[Test]
public function user_can_update_settings()
{
$settingsData = [
'settings' => [
'branding' => [
'logo_url' => 'https://example.com/logo.png',
'primary_color' => '#FF6B6B',
'secondary_color' => '#4ECDC4',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => true,
'custom_domain' => true,
],
'custom_domain' => 'custom.example.com',
],
];
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $settingsData);
$response->assertStatus(200)
->assertJson(['message' => 'Settings erfolgreich aktualisiert.'])
->assertJsonPath('data.settings.branding.primary_color', '#FF6B6B')
->assertJsonPath('data.settings.features.photo_likes_enabled', false)
->assertJsonPath('data.settings.custom_domain', 'custom.example.com');
$this->assertDatabaseHas('tenants', [
'id' => $this->tenant->id,
'settings' => $settingsData['settings'],
]);
}
#[Test]
public function settings_update_requires_valid_data()
{
$invalidData = [
'settings' => [
'branding' => [
'primary_color' => 'invalid-color',
],
],
];
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $invalidData);
$response->assertStatus(422)
->assertJsonValidationErrors([
'settings.branding.primary_color',
]);
}
#[Test]
public function user_can_reset_settings_to_defaults()
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/reset');
$response->assertStatus(200)
->assertJson(['message' => 'Settings auf Standardwerte zurückgesetzt.'])
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
$this->assertDatabaseHas('tenants', [
'id' => $this->tenant->id,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => true,
'event_checklist' => true,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $this->tenant->contact_email,
'event_default_type' => 'general',
]),
]);
}
#[Test]
public function user_can_validate_domain_availability()
{
// Valid domain
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [
'domain' => 'custom.example.com',
]);
$response->assertStatus(200)
->assertJson(['available' => true])
->assertJson(['message' => 'Domain ist verfügbar.']);
// Invalid domain format
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [
'domain' => 'invalid@domain',
]);
$response->assertStatus(200)
->assertJson(['available' => false])
->assertJson(['message' => 'Ungültiges Domain-Format.']);
// Taken domain (create another tenant with same domain)
$otherTenant = Tenant::factory()->create(['custom_domain' => 'taken.example.com']);
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [
'domain' => 'taken.example.com',
]);
$response->assertStatus(200)
->assertJson(['available' => false])
->assertJson(['message' => 'Domain ist bereits vergeben.']);
}
#[Test]
public function domain_validation_requires_domain_parameter()
{
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain');
$response->assertStatus(400)
->assertJson(['error' => 'Domain ist erforderlich.']);
}
#[Test]
public function settings_are_tenant_isolated()
{
$otherTenant = Tenant::factory()->create([
'settings' => json_encode(['branding' => ['primary_color' => '#FF0000']]),
]);
$otherUser = User::factory()->create([
'tenant_id' => $otherTenant->id,
'role' => 'admin',
]);
$otherToken = 'mock-jwt-token-' . $otherTenant->id . '-' . time();
// This tenant's user should not see other tenant's settings
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/settings');
$response->assertStatus(200)
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant
->assertJsonMissing(['#FF0000']); // Other tenant's color
}
}

View File

@@ -0,0 +1,306 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class TaskApiTest extends TenantTestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->mockTenantContext();
}
#[Test]
public function unauthenticated_users_cannot_access_tasks()
{
$response = $this->getJson('/api/v1/tenant/tasks');
$response->assertStatus(401);
}
#[Test]
public function authenticated_user_can_list_tasks()
{
Task::factory(3)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/tasks');
$response->assertStatus(200)
->assertJsonCount(3, 'data');
}
#[Test]
public function tasks_are_tenant_isolated()
{
$otherTenant = Tenant::factory()->create();
Task::factory(2)->create([
'tenant_id' => $otherTenant->id,
'priority' => 'medium',
]);
Task::factory(3)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson('/api/v1/tenant/tasks');
$response->assertStatus(200)
->assertJsonCount(3, 'data');
}
#[Test]
public function user_can_create_task()
{
$taskData = [
'title' => 'Test Task',
'description' => 'Test description',
'priority' => 'high',
'due_date' => now()->addDays(7)->toISOString(),
];
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->postJson('/api/v1/tenant/tasks', $taskData);
$response->assertStatus(201)
->assertJson(['message' => 'Task erfolgreich erstellt.'])
->assertJsonPath('data.title', 'Test Task')
->assertJsonPath('data.tenant_id', $this->tenant->id);
$this->assertDatabaseHas('tasks', [
'title' => 'Test Task',
'tenant_id' => $this->tenant->id,
]);
}
#[Test]
public function task_creation_requires_valid_data()
{
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->postJson('/api/v1/tenant/tasks', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['title']);
}
#[Test]
public function user_can_view_specific_task()
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Viewable Task',
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks/{$task->id}");
$response->assertStatus(200)
->assertJson(['title' => 'Viewable Task']);
}
#[Test]
public function user_cannot_view_other_tenants_task()
{
$otherTenant = Tenant::factory()->create();
$otherTask = Task::factory()->create([
'tenant_id' => $otherTenant->id,
'title' => 'Other Tenant Task',
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks/{$otherTask->id}");
$response->assertStatus(404);
}
#[Test]
public function user_can_update_task()
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Old Title',
'priority' => 'low',
]);
$updateData = [
'title' => 'Updated Title',
'priority' => 'urgent',
];
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData);
$response->assertStatus(200)
->assertJson(['message' => 'Task erfolgreich aktualisiert.'])
->assertJsonPath('data.title', 'Updated Title')
->assertJsonPath('data.priority', 'urgent');
$this->assertDatabaseHas('tasks', [
'id' => $task->id,
'title' => 'Updated Title',
'priority' => 'urgent',
]);
}
#[Test]
public function user_can_delete_task()
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->deleteJson("/api/v1/tenant/tasks/{$task->id}");
$response->assertStatus(200)
->assertJson(['message' => 'Task erfolgreich gelöscht.']);
$this->assertSoftDeleted('tasks', ['id' => $task->id]);
}
#[Test]
public function user_can_assign_task_to_event()
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'event_type_id' => 1,
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->postJson("/api/v1/tenant/tasks/{$task->id}/assign-event/{$event->id}");
$response->assertStatus(200)
->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']);
$this->assertDatabaseHas('event_task', [
'task_id' => $task->id,
'event_id' => $event->id,
]);
}
#[Test]
public function bulk_assign_tasks_to_event_works()
{
$tasks = Task::factory(3)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'event_type_id' => 1,
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->postJson("/api/v1/tenant/tasks/bulk-assign-event/{$event->id}", [
'task_ids' => $tasks->pluck('id')->toArray(),
]);
$response->assertStatus(200)
->assertJson(['message' => '3 Tasks dem Event zugewiesen.']);
$this->assertEquals(3, $event->tasks()->count());
}
#[Test]
public function can_get_tasks_for_specific_event()
{
$event = Event::factory()->create([
'tenant_id' => $this->tenant->id,
'event_type_id' => 1,
]);
$eventTasks = Task::factory(2)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$eventTasks->each(fn($task) => $task->assignedEvents()->attach($event->id));
Task::factory(3)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]); // Other tasks
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks/event/{$event->id}");
$response->assertStatus(200)
->assertJsonCount(2, 'data');
}
#[Test]
public function can_filter_tasks_by_collection()
{
$collection = TaskCollection::factory()->create([
'tenant_id' => $this->tenant->id,
]);
$collectionTasks = Task::factory(2)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]);
$collectionTasks->each(fn($task) => $task->taskCollection()->associate($collection));
Task::factory(3)->create([
'tenant_id' => $this->tenant->id,
'priority' => 'medium',
]); // Other tasks
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}");
$response->assertStatus(200)
->assertJsonCount(2, 'data');
}
#[Test]
public function tasks_can_be_searched()
{
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'First Task',
'priority' => 'medium'
]);
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Search Test',
'priority' => 'medium'
]);
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Another Task',
'priority' => 'medium'
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson('/api/v1/tenant/tasks?search=Search');
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.title', 'Search Test');
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
abstract class TenantTestCase extends TestCase
{
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected string $token;
protected function setUp(): void
{
parent::setUp();
$this->tenant = Tenant::factory()->create([
'name' => 'Test Tenant',
'slug' => 'test-tenant',
]);
$this->tenantUser = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'tenant_id' => $this->tenant->id,
'role' => 'admin',
]);
$this->token = 'mock-jwt-token-' . $this->tenant->id . '-' . time();
}
protected function authenticatedRequest($method, $uri, array $data = [], array $headers = [])
{
$headers['Authorization'] = 'Bearer ' . $this->token;
// Temporarily override the middleware to skip auth and set tenant
$this->app['router']->pushMiddlewareToGroup('api', MockTenantMiddleware::class, 'mock-tenant');
return $this->withHeaders($headers)->json($method, $uri, $data);
}
protected function mockTenantContext()
{
$this->actingAs($this->tenantUser);
// Set tenant globally for tests
$this->app->instance('tenant', $this->tenant);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Tests\Unit;
use App\Models\Event;
use App\Models\Photo;
use App\Models\PurchaseHistory;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TenantModelTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function tenant_has_many_events()
{
$tenant = Tenant::factory()->create();
Event::factory(3)->create(['tenant_id' => $tenant->id]);
$this->assertCount(3, $tenant->events);
}
/** @test */
public function tenant_has_photos_through_events()
{
$tenant = Tenant::factory()->create();
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
Photo::factory(2)->create(['event_id' => $event->id]);
$this->assertCount(2, $tenant->photos);
}
/** @test */
public function tenant_has_many_purchases()
{
$tenant = Tenant::factory()->create();
PurchaseHistory::factory(2)->create(['tenant_id' => $tenant->id]);
$this->assertCount(2, $tenant->purchases);
}
/** @test */
public function active_subscription_returns_true_if_not_expired()
{
$tenant = Tenant::factory()->create([
'subscription_tier' => 'pro',
'subscription_expires_at' => now()->addDays(30),
]);
$this->assertTrue($tenant->active_subscription);
}
/** @test */
public function active_subscription_returns_false_if_expired()
{
$tenant = Tenant::factory()->create([
'subscription_tier' => 'pro',
'subscription_expires_at' => now()->subDays(1),
]);
$this->assertFalse($tenant->active_subscription);
}
/** @test */
public function active_subscription_returns_false_if_no_subscription()
{
$tenant = Tenant::factory()->create([
'subscription_tier' => 'free',
'subscription_expires_at' => null,
]);
$this->assertFalse($tenant->active_subscription);
}
/** @test */
public function can_decrement_credits()
{
$tenant = Tenant::factory()->create(['event_credits_balance' => 10]);
$result = $tenant->decrementCredits(3);
$this->assertTrue($result);
$this->assertEquals(7, $tenant->fresh()->event_credits_balance);
}
/** @test */
public function can_increment_credits()
{
$tenant = Tenant::factory()->create(['event_credits_balance' => 10]);
$result = $tenant->incrementCredits(5);
$this->assertTrue($result);
$this->assertEquals(15, $tenant->fresh()->event_credits_balance);
}
/** @test */
public function decrementing_credits_does_not_go_negative()
{
$tenant = Tenant::factory()->create(['event_credits_balance' => 2]);
$result = $tenant->decrementCredits(5);
$this->assertFalse($result);
$this->assertEquals(2, $tenant->fresh()->event_credits_balance);
}
/** @test */
public function settings_are_properly_cast_as_json()
{
$tenant = Tenant::factory()->create([
'settings' => json_encode(['theme' => 'dark', 'logo' => 'logo.png'])
]);
$this->assertIsArray($tenant->settings);
$this->assertEquals('dark', $tenant->settings['theme']);
}
/** @test */
public function features_are_cast_as_array()
{
$tenant = Tenant::factory()->create([
'features' => ['photo_likes' => true, 'analytics' => false]
]);
$this->assertIsArray($tenant->features);
$this->assertTrue($tenant->features['photo_likes']);
$this->assertFalse($tenant->features['analytics']);
}
}