die tenant admin oauth authentifizierung wurde implementiert und funktioniert jetzt. Zudem wurde das marketing frontend dashboard implementiert.

This commit is contained in:
Codex Agent
2025-11-04 16:14:17 +01:00
parent 92e64c361a
commit fe380689fb
63 changed files with 4239 additions and 1142 deletions

View File

@@ -3,11 +3,9 @@
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Notifications\VerifyEmail;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
use Illuminate\Support\Facades\Auth;
use Tests\TestCase;
class LoginTest extends TestCase
{
@@ -27,7 +25,8 @@ class LoginTest extends TestCase
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
$response->assertRedirect($expectedDefault);
$this->assertEquals('valid@example.com', Auth::user()->email);
}
@@ -45,7 +44,8 @@ class LoginTest extends TestCase
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$expectedDefault = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
$response->assertRedirect($expectedDefault);
$this->assertEquals('validuser', Auth::user()->username);
}
@@ -82,10 +82,32 @@ class LoginTest extends TestCase
]);
$this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false));
$expected = rtrim(route('tenant.admin.app', absolute: false), '/').'/events';
$response->assertRedirect($expected);
$response->assertSessionHas('success', 'Sie sind nun eingeloggt.');
}
public function test_login_honors_return_to_parameter()
{
$user = User::factory()->create([
'email' => 'return@example.com',
'password' => bcrypt('password'),
'email_verified_at' => now(),
]);
$target = route('tenant.admin.app', absolute: false);
$encoded = rtrim(strtr(base64_encode($target), '+/', '-_'), '=');
$response = $this->post(route('login.store'), [
'login' => 'return@example.com',
'password' => 'password',
'return_to' => $encoded,
]);
$this->assertAuthenticated();
$response->assertRedirect($target);
}
public function test_login_redirects_unverified_user_to_verification_notice()
{
$user = User::factory()->create([

View File

@@ -0,0 +1,102 @@
<?php
namespace Tests\Feature\Auth;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use Mockery;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Tests\TestCase;
class TenantAdminGoogleControllerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_redirect_stores_return_to_and_issues_google_redirect(): void
{
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('scopes')->once()->with(['openid', 'profile', 'email'])->andReturnSelf();
$driver->shouldReceive('with')->once()->with(['prompt' => 'select_account'])->andReturnSelf();
$driver->shouldReceive('redirect')->once()->andReturn(new RedirectResponse('https://accounts.google.com'));
$encodedReturn = rtrim(strtr(base64_encode('http://localhost/test'), '+/', '-_'), '=');
$response = $this->get('/event-admin/auth/google?return_to='.$encodedReturn);
$response->assertRedirect('https://accounts.google.com');
$this->assertSame($encodedReturn, session('tenant_oauth_return_to'));
}
public function test_callback_logs_in_tenant_admin_and_redirects_to_encoded_target(): void
{
$tenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]);
$socialiteUser = tap(new SocialiteUser)->map([
'id' => 'google-id-123',
'name' => 'Google Tenant Admin',
'email' => $user->email,
]);
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('user')->once()->andReturn($socialiteUser);
$targetUrl = 'http://localhost:8000/api/v1/oauth/authorize?foo=bar';
$encodedReturn = rtrim(strtr(base64_encode($targetUrl), '+/', '-_'), '=');
$this->withSession([
'tenant_oauth_return_to' => $encodedReturn,
]);
$response = $this->get('/event-admin/auth/google/callback');
$response->assertRedirect($targetUrl);
$this->assertAuthenticatedAs($user);
}
public function test_callback_redirects_back_when_user_not_found(): void
{
$socialiteUser = tap(new SocialiteUser)->map([
'id' => 'missing-user',
'name' => 'Unknown User',
'email' => 'unknown@example.com',
]);
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('user')->once()->andReturn($socialiteUser);
$response = $this->get('/event-admin/auth/google/callback');
$response->assertRedirect();
$this->assertStringContainsString('error=google_no_match', $response->headers->get('Location'));
$this->assertFalse(Auth::check());
}
public function test_callback_handles_socialite_failure(): void
{
$driver = Mockery::mock();
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($driver);
$driver->shouldReceive('user')->once()->andThrow(new \RuntimeException('boom'));
$response = $this->get('/event-admin/auth/google/callback');
$response->assertRedirect();
$this->assertStringContainsString('error=google_failed', $response->headers->get('Location'));
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace Tests\Feature\Dashboard;
use App\Models\Event;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\Task;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class DashboardPageTest extends TestCase
{
use RefreshDatabase;
public function test_unverified_user_can_access_dashboard_with_summary_data(): void
{
$tenant = Tenant::factory()->create([
'event_credits_balance' => 4,
]);
$package = Package::factory()->reseller()->create([
'name_translations' => [
'de' => 'Premium Paket',
'en' => 'Premium Package',
],
'max_events_per_year' => 10,
]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 149.00,
'purchased_at' => now()->subDay(),
'expires_at' => now()->addMonth(),
'used_events' => 1,
'active' => true,
]);
$user = User::factory()
->unverified()
->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]);
$event = Event::factory()->for($tenant)->create([
'status' => 'published',
'is_active' => true,
'date' => now()->addDays(7),
'name' => ['de' => 'Sommerfest', 'en' => 'Summer Party'],
]);
$task = Task::factory()->create([
'tenant_id' => $tenant->id,
'is_completed' => true,
]);
$event->tasks()->attach($task);
Photo::factory()->for($event)->create([
'tenant_id' => $tenant->id,
'created_at' => now()->subDay(),
]);
PackagePurchase::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 149.00,
'type' => 'reseller_subscription',
'provider' => 'paddle',
'purchased_at' => now()->subDay(),
]);
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('dashboard')
->has('metrics', fn (AssertableInertia $metrics) => $metrics
->where('active_events', 1)
->where('total_events', 1)
->where('task_progress', 100)
->where('upcoming_events', 1)
->where('new_photos', 1)
->where('credit_balance', 4)
->etc()
)
->where('tenant.activePackage.name', 'Premium Paket')
->where('tenant.activePackage.remainingEvents', 9)
->has('upcomingEvents', 1)
->has('recentPurchases', 1)
->where('emailVerification.mustVerify', true)
->where('emailVerification.verified', false)
);
}
}

View File

@@ -0,0 +1,204 @@
<?php
namespace Tests\Feature\OAuth;
use App\Models\OAuthClient;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Tests\TestCase;
class AuthorizeTest extends TestCase
{
use RefreshDatabase;
public function test_authorize_redirects_guests_to_login(): void
{
$tenant = Tenant::factory()->create();
$client = $this->createClientForTenant($tenant);
$query = $this->buildAuthorizeQuery($client);
$fullUrl = url('/api/v1/oauth/authorize?'.http_build_query($query));
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$this->assertStringStartsWith(route('tenant.admin.login'), $location);
$parsed = parse_url($location);
$actualQuery = [];
parse_str($parsed['query'] ?? '', $actualQuery);
$this->assertSame('login_required', $actualQuery['error'] ?? null);
$this->assertSame('Please sign in to continue.', $actualQuery['error_description'] ?? null);
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
$this->assertIntendedUrlMatches($query);
}
public function test_authorize_returns_json_payload_for_ajax_guests(): void
{
$tenant = Tenant::factory()->create();
$client = $this->createClientForTenant($tenant);
$query = $this->buildAuthorizeQuery($client);
$response = $this->withHeaders(['Accept' => 'application/json'])
->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertStatus(401)
->assertJson([
'error' => 'login_required',
'error_description' => 'Please sign in to continue.',
]);
$this->assertIntendedUrlMatches($query);
}
public function test_authorize_rejects_when_user_cannot_access_client_tenant(): void
{
$homeTenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $homeTenant->id,
'role' => 'tenant_admin',
]);
$client = $this->createClientForTenant($otherTenant);
$this->actingAs($user);
$query = $this->buildAuthorizeQuery($client);
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$parsed = parse_url($location);
$actualQuery = [];
parse_str($parsed['query'] ?? '', $actualQuery);
$this->assertSame('tenant_mismatch', $actualQuery['error'] ?? null);
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
}
public function test_authorize_redirects_with_error_when_client_unknown(): void
{
$tenant = Tenant::factory()->create();
$this->actingAs(User::factory()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
]));
$query = $this->buildAuthorizeQuery(new OAuthClient([
'client_id' => 'missing-client',
'redirect_uris' => ['http://localhost/callback'],
'scopes' => ['tenant:read', 'tenant:write'],
]));
$response = $this->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertRedirect();
$location = $response->headers->get('Location');
$this->assertNotNull($location);
$parsed = parse_url($location);
$actualQuery = [];
parse_str($parsed['query'] ?? '', $actualQuery);
$this->assertSame('invalid_client', $actualQuery['error'] ?? null);
$this->assertReturnToMatches($query, $actualQuery['return_to'] ?? null);
}
public function test_authorize_returns_json_error_for_tenant_mismatch_when_requested(): void
{
$homeTenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create();
$user = User::factory()->create([
'tenant_id' => $homeTenant->id,
'role' => 'tenant_admin',
]);
$client = $this->createClientForTenant($otherTenant);
$this->actingAs($user);
$query = $this->buildAuthorizeQuery($client);
$response = $this->withHeaders(['Accept' => 'application/json'])
->get('/api/v1/oauth/authorize?'.http_build_query($query));
$response->assertStatus(403)
->assertJson([
'error' => 'tenant_mismatch',
]);
}
private function createClientForTenant(Tenant $tenant): OAuthClient
{
return OAuthClient::create([
'id' => (string) Str::uuid(),
'client_id' => 'tenant-admin-app-'.$tenant->id,
'tenant_id' => $tenant->id,
'client_secret' => null,
'redirect_uris' => ['http://localhost/callback'],
'scopes' => ['tenant:read', 'tenant:write'],
'is_active' => true,
]);
}
private function buildAuthorizeQuery(OAuthClient $client): array
{
return [
'client_id' => $client->client_id,
'redirect_uri' => 'http://localhost/callback',
'response_type' => 'code',
'scope' => 'tenant:read tenant:write',
'state' => Str::random(10),
'code_challenge' => rtrim(strtr(base64_encode(hash('sha256', Str::random(32), true)), '+/', '-_'), '='),
'code_challenge_method' => 'S256',
];
}
private function assertIntendedUrlMatches(array $expectedQuery): void
{
$intended = session('url.intended');
$this->assertNotNull($intended, 'Expected intended URL to be recorded in session.');
$parts = parse_url($intended);
$this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null);
$actualQuery = [];
parse_str($parts['query'] ?? '', $actualQuery);
$this->assertEqualsCanonicalizing($expectedQuery, $actualQuery);
}
private function decodeReturnTo(?string $value): ?string
{
if ($value === null) {
return null;
}
$padded = str_pad($value, strlen($value) + ((4 - (strlen($value) % 4)) % 4), '=');
$normalized = strtr($padded, '-_', '+/');
return base64_decode($normalized) ?: null;
}
private function assertReturnToMatches(array $expectedQuery, ?string $encoded): void
{
$decoded = $this->decodeReturnTo($encoded);
$this->assertNotNull($decoded, 'Failed to decode return_to parameter.');
$parts = parse_url($decoded);
$this->assertSame('/api/v1/oauth/authorize', $parts['path'] ?? null);
$actualQuery = [];
parse_str($parts['query'] ?? '', $actualQuery);
$this->assertEqualsCanonicalizing($expectedQuery, $actualQuery);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Profile;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
class ProfilePageTest extends TestCase
{
use RefreshDatabase;
public function test_profile_page_displays_user_and_package_information(): void
{
$tenant = Tenant::factory()->create([
'event_credits_balance' => 7,
'subscription_status' => 'active',
'subscription_expires_at' => now()->addMonths(3),
]);
$package = Package::factory()->reseller()->create([
'name_translations' => [
'de' => 'Business Paket',
'en' => 'Business Package',
],
]);
TenantPackage::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 199.00,
'purchased_at' => now()->subWeek(),
'expires_at' => now()->addMonths(3),
'used_events' => 1,
'active' => true,
]);
PackagePurchase::factory()->create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 199.00,
'type' => 'reseller_subscription',
'provider' => 'paddle',
'purchased_at' => now()->subWeek(),
]);
$user = User::factory()->unverified()->create([
'tenant_id' => $tenant->id,
'role' => 'tenant_admin',
'name' => 'Alex Beispiel',
'email' => 'alex@example.test',
'preferred_locale' => 'de',
]);
$this->actingAs($user);
$response = $this->get(route('profile.index'));
$response->assertStatus(200)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/Index')
->where('userData.email', 'alex@example.test')
->where('userData.mustVerifyEmail', true)
->where('tenant.activePackage.name', 'Business Paket')
->has('purchases', fn (AssertableInertia $purchases) => $purchases
->where('0.packageName', 'Business Paket')
->etc()
)
);
}
}

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature\Tenant;
use App\Models\EventType;
use App\Models\Package;
use Illuminate\Support\Carbon;
class EventCreditsTest extends TenantTestCase
@@ -12,6 +13,20 @@ class EventCreditsTest extends TenantTestCase
$this->tenant->update(['event_credits_balance' => 0]);
$eventType = EventType::factory()->create();
$package = Package::factory()->create([
'type' => 'endcustomer',
'price' => 0,
'gallery_days' => 30,
]);
$this->tenant->tenantPackages()->create([
'package_id' => $package->id,
'price' => $package->price,
'purchased_at' => now()->subDay(),
'expires_at' => now()->addMonth(),
'active' => true,
]);
$payload = [
'name' => 'Sample Event',
'description' => 'Test description',
@@ -22,9 +37,8 @@ class EventCreditsTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload);
$response->assertStatus(402)
->assertJson([
'error' => 'Insufficient event credits. Please purchase more credits.',
]);
->assertJsonPath('error.code', 'event_credits_exhausted')
->assertJsonPath('error.meta.balance', 0);
$this->tenant->update(['event_credits_balance' => 2]);
@@ -32,15 +46,14 @@ class EventCreditsTest extends TenantTestCase
$createResponse->assertStatus(201)
->assertJsonPath('message', 'Event created successfully')
->assertJsonPath('balance', 1);
->assertJsonPath('data.package.id', $package->id);
$this->tenant->refresh();
$this->assertSame(1, $this->tenant->event_credits_balance);
$createdEventId = $createResponse->json('data.id');
$this->assertDatabaseHas('event_credits_ledger', [
'tenant_id' => $this->tenant->id,
'delta' => -1,
'reason' => 'event_create',
$this->assertNotNull($createdEventId);
$this->assertDatabaseHas('event_packages', [
'event_id' => $createdEventId,
'package_id' => $package->id,
]);
}
}

View File

@@ -133,6 +133,8 @@ class EventListTest extends TenantTestCase
$matchingEvent = collect($response->json('data'))->firstWhere('id', $event->id);
$this->assertNotNull($matchingEvent, 'Event should still be returned even if package record is missing.');
$this->assertNull($matchingEvent['package'], 'Package payload should be null when relation cannot be resolved.');
$this->assertIsArray($matchingEvent['package'], 'Package payload should provide fallback data when relation is missing.');
$this->assertSame($package->id, $matchingEvent['package']['id']);
$this->assertSame((string) $package->price, $matchingEvent['package']['price']);
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
class ProfileApiTest extends TenantTestCase
{
public function test_profile_endpoint_returns_current_user_details(): void
{
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/profile');
$response->assertOk();
$payload = $response->json('data');
$this->assertSame($this->tenantUser->id, $payload['id']);
$this->assertSame($this->tenantUser->email, $payload['email']);
$this->assertSame($this->tenantUser->name, $payload['name']);
$this->assertTrue($payload['email_verified']);
}
public function test_profile_update_allows_name_and_email_changes(): void
{
Notification::fake();
$newEmail = 'updated-'.$this->tenantUser->id.'@example.com';
$response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [
'name' => 'Updated Name',
'email' => $newEmail,
'preferred_locale' => 'en',
]);
$response->assertOk();
$payload = $response->json('data');
$this->assertSame('Updated Name', $payload['name']);
$this->assertSame($newEmail, $payload['email']);
$this->assertFalse($payload['email_verified']);
$this->assertSame('en', $payload['preferred_locale']);
$this->assertDatabaseHas(User::class, [
'id' => $this->tenantUser->id,
'name' => 'Updated Name',
'email' => $newEmail,
'preferred_locale' => 'en',
]);
Notification::assertSentToTimes($this->tenantUser->fresh(), \Illuminate\Auth\Notifications\VerifyEmail::class, 1);
}
public function test_profile_update_requires_current_password_for_password_change(): void
{
$response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [
'name' => $this->tenantUser->name,
'email' => $this->tenantUser->email,
'current_password' => 'wrong-password',
'password' => 'new-secure-password',
'password_confirmation' => 'new-secure-password',
]);
$response->assertStatus(422);
$response->assertJson([
'error' => [
'code' => 'profile.invalid_current_password',
],
]);
}
public function test_profile_update_allows_password_change_with_correct_current_password(): void
{
$newPassword = 'NewStrongPassword123!';
$response = $this->authenticatedRequest('PUT', '/api/v1/tenant/profile', [
'name' => $this->tenantUser->name,
'email' => $this->tenantUser->email,
'current_password' => 'password',
'password' => $newPassword,
'password_confirmation' => $newPassword,
]);
$response->assertOk();
$this->tenantUser->refresh();
$this->assertTrue(Hash::check($newPassword, $this->tenantUser->password));
}
}

View File

@@ -5,16 +5,16 @@ 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
@@ -37,9 +37,9 @@ class SettingsApiTest extends TenantTestCase
$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);
->assertJson(['message' => 'Settings erfolgreich abgerufen.'])
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
}
#[Test]
@@ -64,10 +64,10 @@ class SettingsApiTest extends TenantTestCase
$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');
->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,
@@ -89,9 +89,9 @@ class SettingsApiTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings', $invalidData);
$response->assertStatus(422)
->assertJsonValidationErrors([
'settings.branding.primary_color',
]);
->assertJsonValidationErrors([
'settings.branding.primary_color',
]);
}
#[Test]
@@ -100,9 +100,9 @@ class SettingsApiTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/reset');
$response->assertStatus(200)
->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.'])
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
->assertJson(['message' => 'Settings auf Standardwerte zurueckgesetzt.'])
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6')
->assertJsonPath('data.settings.features.photo_likes_enabled', true);
$this->assertDatabaseHas('tenants', [
'id' => $this->tenant->id,
@@ -135,8 +135,8 @@ class SettingsApiTest extends TenantTestCase
]);
$response->assertStatus(200)
->assertJson(['available' => true])
->assertJson(['message' => 'Domain ist verfuegbar.']);
->assertJson(['available' => true])
->assertJson(['message' => 'Domain ist verfuegbar.']);
// Invalid domain format
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain', [
@@ -144,19 +144,19 @@ class SettingsApiTest extends TenantTestCase
]);
$response->assertStatus(200)
->assertJson(['available' => false])
->assertJson(['message' => 'Ungueltiges Domain-Format.']);
->assertJson(['available' => false])
->assertJson(['message' => 'Ungueltiges 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.']);
->assertJson(['available' => false])
->assertJson(['message' => 'Domain ist bereits vergeben.']);
}
#[Test]
@@ -165,7 +165,8 @@ class SettingsApiTest extends TenantTestCase
$response = $this->authenticatedRequest('POST', '/api/v1/tenant/settings/validate-domain');
$response->assertStatus(400)
->assertJson(['error' => 'Domain ist erforderlich.']);
->assertJsonPath('error.code', 'domain_missing')
->assertJsonPath('error.message', 'Bitte gib eine Domain an.');
}
#[Test]
@@ -178,15 +179,13 @@ class SettingsApiTest extends TenantTestCase
'tenant_id' => $otherTenant->id,
'role' => 'admin',
]);
$otherToken = 'mock-jwt-token-' . $otherTenant->id . '-' . time();
$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
->assertJsonPath('data.settings.branding.primary_color', '#3B82F6') // Default for this tenant
->assertJsonMissing(['#FF0000']); // Other tenant's color
}
}

View File

@@ -8,16 +8,16 @@ 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
@@ -45,7 +45,7 @@ class TaskApiTest extends TenantTestCase
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/tasks');
$response->assertStatus(200)
->assertJsonCount(3, 'data');
->assertJsonCount(3, 'data');
}
#[Test]
@@ -62,11 +62,11 @@ class TaskApiTest extends TenantTestCase
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson('/api/v1/tenant/tasks');
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->getJson('/api/v1/tenant/tasks');
$response->assertStatus(200)
->assertJsonCount(3, 'data');
->assertJsonCount(3, 'data');
}
#[Test]
@@ -79,28 +79,28 @@ class TaskApiTest extends TenantTestCase
'due_date' => now()->addDays(7)->toISOString(),
];
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->postJson('/api/v1/tenant/tasks', $taskData);
$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);
->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,
'title->de' => 'Test Task',
]);
}
#[Test]
public function task_creation_requires_valid_data()
{
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->postJson('/api/v1/tenant/tasks', []);
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->postJson('/api/v1/tenant/tasks', []);
$response->assertStatus(422)
->assertJsonValidationErrors(['title']);
->assertJsonValidationErrors(['title']);
}
#[Test]
@@ -108,15 +108,18 @@ class TaskApiTest extends TenantTestCase
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Viewable Task',
'title' => [
'de' => 'Viewable Task',
'en' => 'Viewable Task',
],
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks/{$task->id}");
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->getJson("/api/v1/tenant/tasks/{$task->id}");
$response->assertStatus(200)
->assertJson(['title' => 'Viewable Task']);
->assertJson(['title' => 'Viewable Task']);
}
#[Test]
@@ -129,8 +132,8 @@ class TaskApiTest extends TenantTestCase
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks/{$otherTask->id}");
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->getJson("/api/v1/tenant/tasks/{$otherTask->id}");
$response->assertStatus(404);
}
@@ -140,7 +143,10 @@ class TaskApiTest extends TenantTestCase
{
$task = Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Old Title',
'title' => [
'de' => 'Old Title',
'en' => 'Old Title',
],
'priority' => 'low',
]);
@@ -149,17 +155,17 @@ class TaskApiTest extends TenantTestCase
'priority' => 'urgent',
];
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->patchJson("/api/v1/tenant/tasks/{$task->id}", $updateData);
$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');
->assertJson(['message' => 'Task erfolgreich aktualisiert.'])
->assertJsonPath('data.title', 'Updated Title')
->assertJsonPath('data.priority', 'urgent');
$this->assertDatabaseHas('tasks', [
'id' => $task->id,
'title' => 'Updated Title',
'title->de' => 'Updated Title',
'priority' => 'urgent',
]);
}
@@ -172,11 +178,11 @@ class TaskApiTest extends TenantTestCase
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->deleteJson("/api/v1/tenant/tasks/{$task->id}");
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->deleteJson("/api/v1/tenant/tasks/{$task->id}");
$response->assertStatus(200)
->assertJson(['message' => 'Task erfolgreich gelöscht.']);
->assertJson(['message' => 'Task erfolgreich gelöscht.']);
$this->assertSoftDeleted('tasks', ['id' => $task->id]);
}
@@ -190,14 +196,13 @@ class TaskApiTest extends TenantTestCase
]);
$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 = $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.']);
->assertJson(['message' => 'Task erfolgreich dem Event zugewiesen.']);
$this->assertDatabaseHas('event_task', [
'task_id' => $task->id,
@@ -214,16 +219,15 @@ class TaskApiTest extends TenantTestCase
]);
$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 = $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.']);
->assertJson(['message' => '3 Tasks dem Event zugewiesen.']);
$this->assertEquals(3, $event->tasks()->count());
}
@@ -233,24 +237,23 @@ class TaskApiTest extends TenantTestCase
{
$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));
$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 = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->getJson("/api/v1/tenant/tasks/event/{$event->id}");
$response->assertStatus(200)
->assertJsonCount(2, 'data');
->assertJsonCount(2, 'data');
}
#[Test]
@@ -272,11 +275,11 @@ class TaskApiTest extends TenantTestCase
'priority' => 'medium',
]); // Other tasks
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}");
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->token])
->getJson("/api/v1/tenant/tasks?collection_id={$collection->id}");
$response->assertStatus(200)
->assertJsonCount(2, 'data');
->assertJsonCount(2, 'data');
}
#[Test]
@@ -284,29 +287,25 @@ class TaskApiTest extends TenantTestCase
{
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'First Task',
'priority' => 'medium'
'title' => ['de' => 'First Task', 'en' => 'First Task'],
'priority' => 'medium',
]);
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Search Test',
'priority' => 'medium'
'title' => ['de' => 'Search Test', 'en' => 'Search Test'],
'priority' => 'medium',
]);
Task::factory()->create([
'tenant_id' => $this->tenant->id,
'title' => 'Another Task',
'priority' => 'medium'
'title' => ['de' => 'Another Task', 'en' => 'Another Task'],
'priority' => 'medium',
]);
$response = $this->withHeaders(['Authorization' => 'Bearer ' . $this->token])
->getJson('/api/v1/tenant/tasks?search=Search');
$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');
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.title', 'Search Test');
}
}

View File

@@ -14,9 +14,13 @@ abstract class TenantTestCase extends TestCase
use RefreshDatabase;
protected Tenant $tenant;
protected User $tenantUser;
protected OAuthClient $oauthClient;
protected string $token;
protected ?string $refreshToken = null;
protected function setUp(): void
@@ -75,6 +79,8 @@ abstract class TenantTestCase extends TestCase
protected function issueTokens(OAuthClient $client, array $scopes = ['tenant:read', 'tenant:write']): array
{
$this->actingAs($this->tenantUser);
$codeVerifier = 'tenant-code-verifier-'.Str::random(32);
$codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');
$state = Str::random(10);
@@ -114,4 +120,3 @@ abstract class TenantTestCase extends TestCase
];
}
}