InstanceSettings::firstOrCreate(['id' => 0])); }); /** * Create a user that belongs to two teams (their auto-created personal team * plus a second team). Boarding is disabled so the middleware does not bounce * the request to the onboarding screen. */ function userWithTwoTeams(): array { $user = User::factory()->create(); $personal = $user->teams->first(); $personal->update(['show_boarding' => false]); $second = Team::factory()->create(['show_boarding' => false]); $user->teams()->attach($second, ['role' => 'owner']); $user->refresh(); return [$user, $personal, $second]; } it('resolves the stored team when the user still belongs to it', function () { [$user, , $second] = userWithTwoTeams(); $user->update(['current_team_id' => $second->id]); expect($user->resolveStoredTeam()?->id)->toBe($second->id); }); it('resolves the only team for single-team users without a stored choice', function () { $user = User::factory()->create(); $user->teams->first()->update(['show_boarding' => false]); expect($user->resolveStoredTeam()?->id)->toBe($user->teams->first()->id); }); it('returns null for multi-team users without a valid stored choice', function () { [$user] = userWithTwoTeams(); expect($user->resolveStoredTeam())->toBeNull(); }); it('ignores a stored team the user no longer belongs to', function () { [$user, , $second] = userWithTwoTeams(); $user->update(['current_team_id' => 99999]); expect($user->resolveStoredTeam())->toBeNull(); // still ambiguous (2 teams), so must pick again $user->update(['current_team_id' => $second->id]); $user->refresh(); expect($user->resolveStoredTeam()?->id)->toBe($second->id); }); it('persists current_team_id when the active team changes via refreshSession', function () { [$user, , $second] = userWithTwoTeams(); $this->actingAs($user); refreshSession($second); expect($user->fresh()->current_team_id)->toBe($second->id) ->and(data_get(session('currentTeam'), 'id'))->toBe($second->id); }); it('redirects a multi-team user with no stored team to the select screen', function () { [$user] = userWithTwoTeams(); $this->actingAs($user) ->get('/') ->assertRedirect(route('team.select')); }); it('restores the stored team for a returning multi-team user', function () { [$user, , $second] = userWithTwoTeams(); $user->update(['current_team_id' => $second->id]); $this->actingAs($user)->get('/'); expect(data_get(session('currentTeam'), 'id'))->toBe($second->id); }); it('does not send a single-team user to the select screen', function () { $user = User::factory()->create(); $only = $user->teams->first(); $only->update(['show_boarding' => false]); // A single-team user who lands on the select screen is bounced straight to // the dashboard with their team activated, never shown a choice. $this->actingAs($user) ->get(route('team.select')) ->assertRedirect(route('dashboard')); expect(data_get(session('currentTeam'), 'id'))->toBe($only->id); }); it('persists the choice and activates the team when selected on the screen', function () { [$user, , $second] = userWithTwoTeams(); Livewire::actingAs($user) ->test(SelectTeam::class) ->call('selectTeam', $second->id) ->assertRedirect(route('dashboard')); expect($user->fresh()->current_team_id)->toBe($second->id) ->and(data_get(session('currentTeam'), 'id'))->toBe($second->id); }); it('lets the livewire update endpoint through for an ambiguous user', function () { [$user] = userWithTwoTeams(); // The selection action runs as a Livewire AJAX POST to /livewire/update. // The team gate must not hijack that request with a redirect to the // selection screen, or the click silently does nothing (HTML != JSON). $response = $this->actingAs($user) ->withHeaders(['X-Livewire' => 'true']) ->post('/livewire/update', []); expect($response->headers->get('Location'))->not->toBe(route('team.select')); }); it('clears the stored team when the member is removed from it', function () { [$user, , $second] = userWithTwoTeams(); $user->update(['current_team_id' => $second->id]); // Simulate the removal event (Team\Member::remove detaches then clears). $user->teams()->detach($second->id); $user->clearStoredTeamIfMatches($second->id); expect($user->fresh()->current_team_id)->toBeNull(); }); it('keeps the stored team when the member is removed from a different team', function () { [$user, $personal, $second] = userWithTwoTeams(); $user->update(['current_team_id' => $second->id]); $user->teams()->detach($personal->id); $user->clearStoredTeamIfMatches($personal->id); expect($user->fresh()->current_team_id)->toBe($second->id); }); it('clears the stored team for members when their team is deleted', function () { [$owner, , $shared] = userWithTwoTeams(); $member = User::factory()->create(); $member->teams()->attach($shared, ['role' => 'member']); $member->update(['current_team_id' => $shared->id]); app(DeleteTeam::class)->handle($shared->fresh(), $owner); expect($member->fresh()->current_team_id)->toBeNull(); }); it('clears the deleting owner stored team when they delete that team', function () { [$owner, $personal, $shared] = userWithTwoTeams(); $owner->update(['current_team_id' => $shared->id]); app(DeleteTeam::class)->handle($shared->fresh(), $owner); expect($owner->fresh()->current_team_id)->toBeNull(); }); it('preserves a newer team selection when clearing a stale team', function () { [$user, $personal, $second] = userWithTwoTeams(); // In-memory model still points at the team being removed ($second)... $user->update(['current_team_id' => $second->id]); // ...but a concurrent request already switched the stored choice to $personal. User::query()->whereKey($user->id)->update(['current_team_id' => $personal->id]); $user->clearStoredTeamIfMatches($second->id); // The atomic WHERE guard must not clobber the newer selection. expect($user->fresh()->current_team_id)->toBe($personal->id); }); it('returns the sole remaining team when the deleting owner has one team left', function () { [$owner, $personal, $shared] = userWithTwoTeams(); $owner->update(['current_team_id' => $shared->id]); $next = app(DeleteTeam::class)->handle($shared->fresh(), $owner); expect($next?->id)->toBe($personal->id); }); it('returns null (picker) when the deleting owner still has multiple teams left', function () { [$owner, , $shared] = userWithTwoTeams(); $third = Team::factory()->create(['show_boarding' => false]); $owner->teams()->attach($third, ['role' => 'owner']); $owner->update(['current_team_id' => $shared->id]); // Deleting the active team leaves personal + third: ambiguous, so no team is // chosen silently and refreshSession(null) routes to the selection screen. $next = app(DeleteTeam::class)->handle($shared->fresh(), $owner->fresh()); expect($next)->toBeNull(); }); it('keeps the active team when the deleted team was not the active one', function () { [$owner, $personal, $shared] = userWithTwoTeams(); $third = Team::factory()->create(['show_boarding' => false]); $owner->teams()->attach($third, ['role' => 'owner']); $owner->update(['current_team_id' => $personal->id]); // Deleting a non-active team must not move the owner off their active team. $next = app(DeleteTeam::class)->handle($shared->fresh(), $owner->fresh()); expect($next?->id)->toBe($personal->id); }); it('does not persist current_team_id while impersonating', function () { [$user, , $second] = userWithTwoTeams(); $user->update(['current_team_id' => $second->id]); $this->actingAs($user); session(['impersonating' => true]); // Viewing a user's account switches the session team but must never // overwrite that user's stored last-active team. refreshSession($user->teams->first()); expect(data_get(session('currentTeam'), 'id'))->toBe($user->teams->first()->id) ->and($user->fresh()->current_team_id)->toBe($second->id); }); it('bounces users who already have an active team away from the select screen', function () { [$user, , $second] = userWithTwoTeams(); $user->update(['current_team_id' => $second->id]); refreshSession($second); Livewire::actingAs($user) ->test(SelectTeam::class) ->assertRedirect(route('dashboard')); });