From ee51ba1cd4d3b175e0a0c2e39a728e7cf699211c Mon Sep 17 00:00:00 2001 From: tmolick Date: Sun, 18 Oct 2026 14:52:45 -0700 Subject: [PATCH] Add tests across the app --- tests/Feature/AttendeeLogTest.php | 246 ++++++++++++++++++++++++++ tests/Feature/AuthTest.php | 207 ++++++++++++++++++++++ tests/Feature/ConCatTest.php | 145 +++++++++++++++ tests/Feature/ConsoleCommandsTest.php | 183 +++++++++++++++++++ tests/Feature/DepartmentTest.php | 105 +++++++++++ tests/Feature/EventTest.php | 121 +++++++++++++ tests/Feature/KioskTest.php | 87 +++++++++ tests/Feature/ManagementTest.php | 126 +++++++++++++ tests/Feature/NotificationTest.php | 96 ++++++++++ tests/Feature/ReportsTest.php | 210 ++++++++++++++++++++++ tests/Feature/RewardTest.php | 140 +++++++++++++++ tests/Feature/SettingTest.php | 127 +++++++++++++ tests/Feature/SharedPageDataTest.php | 77 ++++++++ tests/Feature/TelegramBotTest.php | 228 ++++++++++++++++++++++++ tests/Feature/TimeBonusTest.php | 132 ++++++++++++++ tests/Feature/TimeCalculationTest.php | 218 +++++++++++++++++++++++ tests/Feature/UserManagementTest.php | 214 ++++++++++++++++++++++ tests/Feature/VolunteerTest.php | 226 +++++++++++++++++++++++ tests/Pest.php | 55 ++++++ tests/TestCase.php | 16 +- tests/Unit/RoleTest.php | 50 ++++++ tests/Unit/TimeFormattingTest.php | 36 ++++ 22 files changed, 3044 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/AttendeeLogTest.php create mode 100644 tests/Feature/AuthTest.php create mode 100644 tests/Feature/ConCatTest.php create mode 100644 tests/Feature/ConsoleCommandsTest.php create mode 100644 tests/Feature/DepartmentTest.php create mode 100644 tests/Feature/EventTest.php create mode 100644 tests/Feature/KioskTest.php create mode 100644 tests/Feature/ManagementTest.php create mode 100644 tests/Feature/NotificationTest.php create mode 100644 tests/Feature/ReportsTest.php create mode 100644 tests/Feature/RewardTest.php create mode 100644 tests/Feature/SettingTest.php create mode 100644 tests/Feature/SharedPageDataTest.php create mode 100644 tests/Feature/TelegramBotTest.php create mode 100644 tests/Feature/TimeBonusTest.php create mode 100644 tests/Feature/TimeCalculationTest.php create mode 100644 tests/Feature/UserManagementTest.php create mode 100644 tests/Feature/VolunteerTest.php create mode 100644 tests/Unit/RoleTest.php create mode 100644 tests/Unit/TimeFormattingTest.php diff --git a/tests/Feature/AttendeeLogTest.php b/tests/Feature/AttendeeLogTest.php new file mode 100644 index 00000000..fd6a57d2 --- /dev/null +++ b/tests/Feature/AttendeeLogTest.php @@ -0,0 +1,246 @@ +withoutVite(); + $this->event = activateEvent(); + $this->admin = userWithRole(Role::Admin); + $this->manager = userWithRole(Role::Manager); + $this->log = new AttendeeLog(['name' => 'Sponsor Lounge']); + $this->log->event_id = $this->event->id; + $this->log->save(); + $this->gatekeeper = User::factory()->create(); + $this->log->users()->attach($this->gatekeeper, ['type' => 'gatekeeper']); +}); + +/** + * Creates an attendee log for an event + */ +function attendeeLogFor(Event $event, string $name): AttendeeLog { + $log = new AttendeeLog(['name' => $name]); + $log->event_id = $event->id; + $log->save(); + return $log; +} + +it('lists every log of the active event for managers with counts', function () { + attendeeLogFor($this->event, 'Dance'); + attendeeLogFor(Event::factory()->create(), 'Old Lounge'); + + $this->actingAs($this->manager) + ->getJson(route('attendee-logs.index')) + ->assertOk() + ->assertJsonCount(2, 'attendee_logs') + ->assertJsonPath( + 'attendee_logs', + fn (array $logs) => collect($logs)->firstWhere('id', $this->log->id)['gatekeepers_count'] === 1, + ); +}); + +it('lists only the logs gatekeepers keep', function () { + attendeeLogFor($this->event, 'Dance'); + + $this->actingAs($this->gatekeeper) + ->getJson(route('attendee-logs.index')) + ->assertOk() + ->assertJsonCount(1, 'attendee_logs') + ->assertJsonPath('attendee_logs.0.id', $this->log->id); +}); + +it('does not list logs for other volunteers or for events that are not active', function () { + $this->actingAs(User::factory()->create())->getJson(route('attendee-logs.index'))->assertForbidden(); + + $old = Event::factory()->create(); + attendeeLogFor($old, 'Old Lounge')->users()->attach($this->gatekeeper, ['type' => 'gatekeeper']); + $this->actingAs($this->gatekeeper)->getJson(route('events.attendee-logs.index', $old))->assertForbidden(); + $this->actingAs($this->manager) + ->getJson(route('events.attendee-logs.index', $old)) + ->assertOk() + ->assertJsonCount(1, 'attendee_logs'); +}); + +it('renders the attendee log index page', function () { + $this->actingAs($this->manager) + ->get(route('attendee-logs.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('AttendeeLogIndex') + ->has('attendeeLogs', 1) + ->has('events', 1)); + + $this->actingAs($this->gatekeeper) + ->get(route('attendee-logs.index')) + ->assertInertia(fn (AssertableInertia $page) => $page->where('events', null)); +}); + +it('shows logs to their gatekeepers only while the event is active', function () { + $this->actingAs($this->gatekeeper) + ->getJson(route('attendee-logs.show', $this->log)) + ->assertOk() + ->assertJsonPath('attendee_log.name', 'Sponsor Lounge'); + $this->actingAs(User::factory()->create())->getJson(route('attendee-logs.show', $this->log))->assertForbidden(); + + App\Models\Setting::set('active-event', null); + $this->actingAs($this->gatekeeper)->getJson(route('attendee-logs.show', $this->log))->assertForbidden(); + $this->actingAs($this->manager)->getJson(route('attendee-logs.show', $this->log))->assertOk(); +}); + +it('lets admins create logs with names unique to the event', function () { + $this->actingAs($this->admin) + ->postJson(route('events.attendee-logs.store', $this->event), [ + 'name' => 'Dance', + 'allow_staff' => true, + 'min_volunteer_hours' => 12, + ]) + ->assertOk() + ->assertJsonPath('attendee_log.event_id', $this->event->id); + + $log = AttendeeLog::whereName('Dance')->sole(); + expect($log->allow_staff)->toBeTrue() + ->and($log->min_volunteer_hours)->toBe(12.0) + ->and($log->hasEntryRequirements())->toBeTrue(); + + $this->actingAs($this->admin) + ->postJson(route('events.attendee-logs.store', $this->event), ['name' => 'Sponsor Lounge']) + ->assertUnprocessable() + ->assertJsonValidationErrors('name'); +}); + +it('rejects duplicate allowed registration levels, ignoring case', function () { + $this->actingAs($this->admin) + ->postJson(route('events.attendee-logs.store', $this->event), [ + 'name' => 'Dance', + 'allowed_registration_levels' => ['Sponsor', 'sponsor'], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('allowed_registration_levels.1'); +}); + +it('only lets admins create and delete logs', function () { + $this->actingAs($this->manager) + ->postJson(route('events.attendee-logs.store', $this->event), ['name' => 'Dance']) + ->assertForbidden(); + $this->actingAs($this->manager)->deleteJson(route('attendee-logs.destroy', $this->log))->assertForbidden(); + + $this->actingAs($this->admin)->deleteJson(route('attendee-logs.destroy', $this->log))->assertStatus(205); + expect($this->log->fresh()->trashed())->toBeTrue() + ->and($this->log->fresh()->display_name)->toBe('Sponsor Lounge (del)'); +}); + +it('lets managers add gatekeepers, but not gatekeepers', function () { + $newGatekeeper = User::factory()->create(); + + $this->actingAs($this->gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $newGatekeeper->badge_id, 'type' => 'gatekeeper']) + ->assertForbidden(); + + $this->actingAs($this->manager) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $newGatekeeper->badge_id, 'type' => 'gatekeeper']) + ->assertOk() + ->assertJsonPath('type', 'gatekeeper'); + + expect($this->log->hasGatekeeper($newGatekeeper))->toBeTrue() + ->and($newGatekeeper->isGatekeeper())->toBeTrue(); +}); + +it('only lets admins manage gatekeepers of logs for other events', function () { + $oldLog = attendeeLogFor(Event::factory()->create(), 'Old Lounge'); + $user = User::factory()->create(); + + $this->actingAs($this->manager) + ->putJson(route('attendee-logs.users.store', $oldLog), ['badge_id' => $user->badge_id, 'type' => 'gatekeeper']) + ->assertForbidden(); + $this->actingAs($this->admin) + ->putJson(route('attendee-logs.users.store', $oldLog), ['badge_id' => $user->badge_id, 'type' => 'gatekeeper']) + ->assertOk(); +}); + +it('lets gatekeepers add attendees to logs without requirements, creating them from ConCat', function () { + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('getRegistration')->once()->with(1234)->andReturn((object) [ + 'badgeName' => 'Foxy', + 'user' => (object) ['id' => 1234, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face'], + ]); + + $this->actingAs($this->gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => 1234]) + ->assertOk() + ->assertJsonPath('user.badge_name', 'Foxy') + ->assertJsonPath('type', 'attendee') + ->assertJsonPath('overridden', false); + + // Attendees created this way don't have their real names stored + $user = User::whereBadgeId(1234)->sole(); + expect($user->role)->toBe(Role::Attendee) + ->and($user->first_name)->toBe('Unidentified') + ->and($user->last_name)->toBe('Attendee') + ->and($this->log->hasAttendee($user))->toBeTrue(); +}); + +it('does not add existing attendees twice', function () { + $attendee = User::factory()->create(['badge_name' => 'Foxy']); + $this->log->users()->attach($attendee, ['type' => 'attendee']); + + $this->actingAs($this->gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), ['badge_id' => $attendee->badge_id]) + ->assertUnprocessable() + ->assertJsonPath('error', "Attendee Foxy (#{$attendee->badge_id}) is already present in the log."); +}); + +it('validates badge IDs and entry types', function (array $data, string $field) { + $this->actingAs($this->gatekeeper) + ->putJson(route('attendee-logs.users.store', $this->log), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + [['badge_id' => 'abc'], 'badge_id'], + [['badge_id' => 123456789], 'badge_id'], + [['badge_id' => 1234, 'type' => 'bouncer'], 'type'], +]); + +it('removes attendees and gatekeepers from logs', function () { + $attendee = User::factory()->create(); + $this->log->users()->attach($attendee, ['type' => 'attendee']); + + $this->actingAs($this->gatekeeper) + ->deleteJson(route('attendee-logs.users.destroy', [$this->log, 'attendee', $attendee])) + ->assertStatus(205); + expect($this->log->hasAttendee($attendee))->toBeFalse(); + + $this->actingAs($this->gatekeeper) + ->deleteJson(route('attendee-logs.users.destroy', [$this->log, 'gatekeeper', $this->gatekeeper])) + ->assertForbidden(); + $this->actingAs($this->manager) + ->deleteJson(route('attendee-logs.users.destroy', [$this->log, 'gatekeeper', $this->gatekeeper])) + ->assertStatus(205); + expect($this->log->hasGatekeeper($this->gatekeeper))->toBeFalse(); +}); + +it('reports users that are not in the log when removing them', function () { + $this->actingAs($this->manager) + ->deleteJson(route('attendee-logs.users.destroy', [$this->log, 'attendee', User::factory()->create()])) + ->assertNotFound(); +}); + +it('checks registrations against allowed levels by product ID or name, ignoring case and spaces', function () { + $this->log->allowed_registration_levels = [' sponsor ', 'prod-42']; + + expect($this->log->allowsRegistration((object) ['productId' => 'x', 'productName' => 'SPONSOR']))->toBeTrue() + ->and($this->log->allowsRegistration((object) ['productId' => 'PROD-42', 'productName' => 'Other']))->toBeTrue() + ->and($this->log->allowsRegistration((object) ['productId' => 'x', 'productName' => 'Attendee']))->toBeFalse() + ->and($this->log->allowsRegistration((object) []))->toBeFalse(); + + $this->log->allowed_registration_levels = []; + expect($this->log->isRestrictedByRegistrationLevel())->toBeFalse() + ->and($this->log->allowsRegistration((object) []))->toBeTrue() + ->and($this->log->hasEntryRequirements())->toBeFalse(); +}); diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php new file mode 100644 index 00000000..7031c405 --- /dev/null +++ b/tests/Feature/AuthTest.php @@ -0,0 +1,207 @@ +withoutVite(); + activateEvent(); +}); + +/** + * Creates a quick code for a user + */ +function quickCodeFor(User $user, ?string $code = null): QuickCode { + $quickCode = new QuickCode; + $quickCode->user_id = $user->id; + if ($code) $quickCode->code = $code; + $quickCode->save(); + return $quickCode; +} + +/** + * Builds an OAuth user like the ConCat Socialite provider returns + */ +function fakeOAuthUser(int $id, array $raw = []): OAuthUser { + $raw = ['id' => $id, 'username' => "user{$id}", 'firstName' => 'Test', 'lastName' => 'User', 'badgeName' => "Badge {$id}", ...$raw]; + return (new OAuthUser)->setRaw($raw)->map(['id' => $id, 'nickname' => $raw['username']])->setToken('oauth-token'); +} + +it('redirects guests to the login page', function () { + $this->get(route('volunteer.index'))->assertRedirect(route('auth.login')); +}); + +it('shows the login page to guests only', function () { + $this->get(route('auth.login')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->component('Login')); + + $this->actingAs(User::factory()->create()) + ->get(route('auth.login')) + ->assertRedirect(route('volunteer.index')); +}); + +it('redirects to ConCat to start the OAuth flow', function () { + Socialite::shouldReceive('driver->scopes->redirect')->andReturn(redirect('https://concat.test/oauth/authorize')); + + $this->get(route('auth.redirect'))->assertRedirect('https://concat.test/oauth/authorize'); +}); + +it('creates a volunteer from the OAuth callback and logs them in', function () { + Socialite::shouldReceive('driver->user')->andReturn(fakeOAuthUser(1234)); + + $this->get(route('auth.callback')) + ->assertRedirect('/') + ->assertSessionHas('conCatToken', 'oauth-token'); + + $user = User::whereBadgeId(1234)->firstOrFail(); + expect($user->username)->toBe('user1234') + ->and($user->badge_name)->toBe('Badge 1234') + ->and($user->fresh()->role)->toBe(Role::Volunteer); + $this->assertAuthenticatedAs($user); +}); + +it('updates existing users from the OAuth callback and promotes attendees to volunteers', function () { + $user = User::factory()->create(['badge_id' => 1234, 'role' => Role::Attendee, 'badge_name' => 'Old Name']); + Socialite::shouldReceive('driver->user')->andReturn(fakeOAuthUser(1234, ['badgeName' => 'New Name'])); + + $this->get(route('auth.callback'))->assertRedirect(); + + $user->refresh(); + expect($user->badge_name)->toBe('New Name') + ->and($user->role)->toBe(Role::Volunteer) + ->and(User::count())->toBe(1); +}); + +it('does not change the role of existing users above attendee in the OAuth callback', function () { + $user = userWithRole(Role::Manager, ['badge_id' => 1234]); + Socialite::shouldReceive('driver->user')->andReturn(fakeOAuthUser(1234)); + + $this->get(route('auth.callback'))->assertRedirect(); + + expect($user->fresh()->role)->toBe(Role::Manager); +}); + +it('logs in with a quick code, ignoring case, and only once', function () { + EventFacade::fake([QuickCodeLogin::class]); + $user = User::factory()->create(); + quickCodeFor($user, 'AB12'); + + $this->postJson(route('auth.quickcode.post'), ['code' => 'ab12'])->assertStatus(205); + + $this->assertAuthenticatedAs($user); + expect(QuickCode::count())->toBe(0); + EventFacade::assertDispatched(QuickCodeLogin::class); +}); + +it('redirects to the volunteer page after a quick code login from a form', function () { + $user = User::factory()->create(); + quickCodeFor($user, 'AB12'); + + $this->post(route('auth.quickcode.post'), ['code' => 'AB12'])->assertRedirect(route('volunteer.index')); +}); + +it('rejects unknown and expired quick codes', function () { + $user = User::factory()->create(); + quickCodeFor($user, 'AB12'); + + $this->postJson(route('auth.quickcode.post'), ['code' => 'ZZ99']) + ->assertUnauthorized() + ->assertJsonPath('errors', 'Quick code not recognized.'); + + $this->travel(31)->seconds(); + $this->postJson(route('auth.quickcode.post'), ['code' => 'AB12'])->assertUnauthorized(); + + $this->from(route('auth.login')) + ->post(route('auth.quickcode.post'), ['code' => 'AB12']) + ->assertRedirect(route('auth.login')) + ->assertSessionHasErrors('code'); + $this->assertGuest(); +}); + +it('validates the format of quick codes', function (string $code) { + $this->postJson(route('auth.quickcode.post'), ['code' => $code]) + ->assertUnprocessable() + ->assertJsonValidationErrors('code'); +})->with(['ABC', 'ABCDE', 'AB-1']); + +it('rate limits failed quick code attempts', function () { + $user = User::factory()->create(); + quickCodeFor($user, 'AB12'); + + foreach (range(1, 5) as $_) { + $this->postJson(route('auth.quickcode.post'), ['code' => 'ZZ99'])->assertUnauthorized(); + } + + // Even a valid code is refused once the limit is reached + $this->postJson(route('auth.quickcode.post'), ['code' => 'AB12'])->assertTooManyRequests(); + $this->assertGuest(); +}); + +it('logs out and returns to the login page', function () { + $this->actingAs(User::factory()->create()) + ->get(route('auth.logout')) + ->assertRedirect(route('auth.login')); + $this->assertGuest(); + + $this->get(route('auth.logout'))->assertRedirect(route('auth.login')); +}); + +it('logs kiosk sessions out of ConCat too', function () { + config(['services.concat.instance_uri' => 'https://concat.test', 'services.concat.client_id' => 'client']); + $kiosk = Kiosk::create(); + + $this->actingAs(User::factory()->create()) + ->withSession(['conCatToken' => 'oauth-token']) + ->withCookie('kiosk', $kiosk->session_key) + ->get(route('auth.logout')) + ->assertRedirect('https://concat.test/oauth/logout?client_id=client&access_token=oauth-token&next=' . urlencode(route('auth.login'))); +}); + +it('does not log out of ConCat outside of kiosks or in dev mode', function () { + $kiosk = Kiosk::create(); + + $this->actingAs(User::factory()->create()) + ->withSession(['conCatToken' => 'oauth-token']) + ->get(route('auth.logout')) + ->assertRedirect(route('auth.login')); + + Setting::set('dev-mode', true); + $this->actingAs(User::factory()->create()) + ->withSession(['conCatToken' => 'oauth-token']) + ->withCookie('kiosk', $kiosk->session_key) + ->get(route('auth.logout')) + ->assertRedirect(route('auth.login')); +}); + +it('sends banned users to the suspension notice', function () { + $banned = userWithRole(Role::Banned); + + $this->actingAs($banned)->get(route('volunteer.index'))->assertRedirect(route('auth.banned')); + $this->actingAs($banned) + ->get(route('auth.banned')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->component('Suspended')); +}); + +it('sends users that are not banned away from the suspension notice', function () { + $this->actingAs(User::factory()->create()) + ->get(route('auth.banned')) + ->assertRedirect(route('volunteer.index')); +}); + +it('denies banned users through every gate', function () { + $banned = userWithRole(Role::Banned); + expect($banned->can('viewAny', User::class))->toBeFalse(); +}); diff --git a/tests/Feature/ConCatTest.php b/tests/Feature/ConCatTest.php new file mode 100644 index 00000000..44e1c390 --- /dev/null +++ b/tests/Feature/ConCatTest.php @@ -0,0 +1,145 @@ + $responses + * @param array $history Filled with the requests made + */ +function mockHttpClient(array $responses, array &$history, array $options = []): Client { + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($history)); + return new Client(['handler' => $stack, ...$options]); +} + +/** + * Creates a ConCat API client whose requests are answered with queued responses + */ +function mockConCatClient(array $responses, array &$history): ConCatApiClient { + $client = new ConCatApiClient(['instance_uri' => 'https://concat.test', 'client_id' => 'id', 'client_secret' => 'secret']); + $httpClient = mockHttpClient($responses, $history, ['base_uri' => 'https://concat.test']); + (fn () => $this->httpClient = $httpClient)->call($client); + return $client; +} + +/** + * Builds a JSON response + */ +function jsonResponse(mixed $data, int $status = 200): Response { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($data)); +} + +it('starts out unauthorized', function () { + $history = []; + expect(mockConCatClient([], $history)->isAuthorized())->toBeFalse(); +}); + +it('retrieves single entities', function () { + $history = []; + $client = mockConCatClient([ + jsonResponse(['id' => 1234, 'username' => 'foxy']), + jsonResponse(['badgeName' => 'Foxy']), + jsonResponse(['id' => 5]), + ], $history); + + expect($client->getUser(1234)->username)->toBe('foxy') + ->and($client->getRegistration(1234)->badgeName)->toBe('Foxy') + ->and($client->getVolunteer(1234)->id)->toBe(5) + ->and(array_map(fn ($entry) => (string) $entry['request']->getUri(), $history))->toBe([ + 'https://concat.test/api/v0/users/1234', + 'https://concat.test/api/v0/users/1234/registration', + 'https://concat.test/api/v0/users/1234/volunteer', + ]); +}); + +it('follows pagination to retrieve every result', function () { + $history = []; + $client = mockConCatClient([ + jsonResponse(['data' => [['id' => 1], ['id' => 2]], 'hasMore' => true, 'nextPage' => 'page-2']), + jsonResponse(['data' => [['id' => 3]], 'hasMore' => false]), + ], $history); + + $results = $client->searchVolunteers(); + + expect(array_column($results, 'id'))->toBe([1, 2, 3]) + ->and((string) $history[0]['request']->getBody())->toBe('{}') + ->and(json_decode($history[1]['request']->getBody(), true))->toBe(['nextPage' => 'page-2']); +}); + +it('searches registrations by user IDs in chunks of 100', function () { + $history = []; + $client = mockConCatClient([ + jsonResponse(['data' => [['id' => 'a']], 'hasMore' => false]), + jsonResponse(['data' => [['id' => 'b']], 'hasMore' => false]), + ], $history); + + $results = $client->searchRegistrationsByUserIds(array_map('strval', range(1, 150))); + + $filters = array_map(fn ($entry) => json_decode($entry['request']->getBody(), true)['filter']['userIds'], $history); + expect(array_column($results, 'id'))->toBe(['a', 'b']) + ->and(count($filters[0]))->toBe(100) + ->and(count($filters[1]))->toBe(50); +}); + +it('passes on client errors when not authorized', function () { + $history = []; + $client = mockConCatClient([new Response(404)], $history); + + $client->getUser(1234); +})->throws(GuzzleHttp\Exception\ClientException::class); + +/** + * Creates the ConCat Socialite provider with an HTTP client answering with queued responses + */ +function mockConCatSocialite(array $responses, array &$history): ConCatSocialiteProvider { + $provider = new ConCatSocialiteProvider(request(), 'id', 'secret', 'https://tracker.test/auth/callback'); + $provider->setConfig(new Config('id', 'secret', 'https://tracker.test/auth/callback', ['instance_uri' => 'https://concat.test'])); + $provider->setHttpClient(mockHttpClient($responses, $history)); + return $provider; +} + +it('builds the ConCat authorization URL', function () { + $history = []; + $url = mockConCatSocialite([], $history)->stateless()->redirect()->getTargetUrl(); + + expect($url)->toStartWith('https://concat.test/oauth/authorize?') + ->toContain('client_id=id') + ->toContain('redirect_uri=' . urlencode('https://tracker.test/auth/callback')); +}); + +it('retrieves the OAuth user along with their badge name', function () { + $history = []; + $provider = mockConCatSocialite([ + jsonResponse(['id' => 1234, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face', 'profilePictureUrl' => null]), + jsonResponse(['badgeName' => 'Foxy']), + ], $history); + + $user = $provider->userFromToken('oauth-token'); + + expect($user->getId())->toBe(1234) + ->and($user->getNickname())->toBe('foxy') + ->and($user->getName())->toBe('Fox Face') + ->and($user->user['badgeName'])->toBe('Foxy') + ->and($history[0]['request']->getHeaderLine('Authorization'))->toBe('Bearer oauth-token') + ->and((string) $history[1]['request']->getUri())->toBe('https://concat.test/api/v0/users/1234/registration'); +}); + +it('retrieves OAuth users without a registration', function () { + $history = []; + $provider = mockConCatSocialite([ + jsonResponse(['id' => 1234, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face', 'profilePictureUrl' => null]), + new GuzzleHttp\Exception\ClientException('Not Found', new GuzzleRequest('GET', '/'), new Response(404)), + ], $history); + + expect($provider->userFromToken('oauth-token')->user['badgeName'])->toBeNull(); +}); diff --git a/tests/Feature/ConsoleCommandsTest.php b/tests/Feature/ConsoleCommandsTest.php new file mode 100644 index 00000000..5a7c9a3b --- /dev/null +++ b/tests/Feature/ConsoleCommandsTest.php @@ -0,0 +1,183 @@ +event = activateEvent(); + $this->department = Department::factory()->create(['event_id' => $this->event->id]); +}); + +it('stops ongoing time entries, crediting at most an hour, and notifies their users', function () { + Notification::fake(); + $this->freezeSecond(); + $long = TimeEntry::factory()->create(['start' => now()->subHours(5), 'stop' => null]); + $short = TimeEntry::factory()->create(['start' => now()->subMinutes(30), 'stop' => null]); + $finished = TimeEntry::factory()->finished()->notAutoStopped()->create(); + + $this->artisan('tracker:stop-ongoing') + ->expectsOutput('Stopping 2 ongoing time entries...') + ->expectsOutput('2 ongoing time entries stopped.') + ->assertSuccessful(); + + $long->refresh(); + $short->refresh(); + expect($long->stop->equalTo($long->start->avoidMutation()->addHour()))->toBeTrue() + ->and($long->auto)->toBeTrue() + ->and($short->stop->equalTo(now()))->toBeTrue() + ->and($short->auto)->toBeTrue() + ->and($finished->fresh()->auto)->toBeFalse(); + Notification::assertSentTo($long->user, TimeEntryAutoStopped::class); + Notification::assertSentTo($short->user, TimeEntryAutoStopped::class); +}); + +it('reports when there are no ongoing time entries to stop', function () { + $this->artisan('tracker:stop-ongoing') + ->expectsOutput('No ongoing time entries to stop.') + ->assertSuccessful(); +}); + +it('prefixes output with a timestamp when loggable', function () { + // The timestamp comes from PHP's date(), so it can't be frozen + $this->artisan('tracker:stop-ongoing', ['--loggable' => true]) + ->expectsOutputToContain('] info: No ongoing time entries to stop.') + ->assertSuccessful(); +}); + +it('notifies volunteers of rewards they newly qualify for, once', function () { + $user = User::factory()->telegramUnlinked()->create(); + timeEntryFor($user, $this->department, now()->subDay(), 9 * 60); + $claimed = Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 2]); + $eligible = Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 8]); + Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 12]); + $claim = new RewardClaim; + $claim->user_id = $user->id; + $claim->reward_id = $claimed->id; + $claim->save(); + + $this->artisan('tracker:notify-rewards') + ->expectsOutput('Notified 1 user of eligible rewards.') + ->assertSuccessful(); + + $notification = $user->notifications()->sole(); + expect($notification->type)->toBe(RewardAvailable::class) + ->and($notification->data['reward_id'])->toBe($eligible->id); + + $this->artisan('tracker:notify-rewards') + ->expectsOutput('Notified 0 users of eligible rewards.') + ->assertSuccessful(); + expect($user->notifications()->count())->toBe(1); +}); + +it('does not notify for rewards without an active event', function () { + Setting::set('active-event', null); + + $this->artisan('tracker:notify-rewards') + ->expectsOutput('No active event to process time entries for.') + ->assertSuccessful(); +}); + +it('fills in unknown users from ConCat', function () { + $withRegistration = User::factory()->create(['username' => 'unknown', 'badge_id' => 1111, 'badge_name' => null]); + $withUserOnly = User::factory()->create(['username' => 'unknown', 'badge_id' => 2222]); + $missing = User::factory()->create(['username' => 'unknown', 'badge_id' => 3333]); + + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('searchRegistrationsByUserIds')->once()->with(['1111', '2222', '3333'])->andReturn([ + (object) [ + 'badgeName' => 'Foxy', + 'user' => (object) ['id' => 1111, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face'], + ], + ]); + ConCat::shouldReceive('getUser')->with(2222)->andReturn( + (object) ['id' => 2222, 'username' => 'wolfy', 'firstName' => 'Wolf', 'lastName' => 'Face'], + ); + ConCat::shouldReceive('getUser')->with(3333)->andThrow(new RuntimeException('Not found')); + + $this->artisan('auth:fetch-unknown') + ->expectsOutput('There are 3 unknown users to look up.') + ->expectsOutput('Populated 2 unknown users.') + ->assertSuccessful(); + + expect($withRegistration->fresh()->username)->toBe('foxy') + ->and($withRegistration->fresh()->badge_name)->toBe('Foxy') + ->and($withUserOnly->fresh()->username)->toBe('wolfy') + ->and($missing->fresh()->username)->toBe('unknown'); +}); + +it('does not contact ConCat when there are no unknown users', function () { + ConCat::shouldReceive('authorize')->never(); + + $this->artisan('auth:fetch-unknown') + ->expectsOutput('No unknown users to retrieve details for.') + ->assertSuccessful(); +}); + +it('sets the role of a user by badge ID or ID', function () { + $user = User::factory()->create(['badge_id' => 1234, 'badge_name' => 'Foxy']); + + $this->artisan('auth:set-role', ['user' => '1234', 'role' => 'manager']) + ->expectsOutput('Changed the role of Foxy (#1234) from Volunteer to Manager.') + ->assertSuccessful(); + expect($user->fresh()->role)->toBe(Role::Manager); + + $this->artisan('auth:set-role', ['user' => $user->id, 'role' => 'Admin'])->assertSuccessful(); + expect($user->fresh()->role)->toBe(Role::Admin); +}); + +it('sends the visible bot commands to Telegram', function () { + $telegram = fakeTelegram(); + $telegram->shouldReceive('getCommands')->andReturn(['help' => new HelpCommand, 'plzhelp' => new PlzHelpCommand]); + $telegram->shouldReceive('setMyCommands')->once()->with(Mockery::on( + fn (array $params) => $params['commands']->all() === [ + ['command' => 'help', 'description' => 'View status and available commands'], + ], + )); + + $this->artisan('telegram:set-commands')->expectsOutput('Command list sent to Telegram.')->assertSuccessful(); +}); + +it('sets and removes the Telegram webhook', function () { + $telegram = fakeTelegram(); + $telegram->shouldReceive('setWebhook')->once()->with(Mockery::on( + fn (array $params) => $params['url'] === route('telegram.tracker.webhook'), + )); + $telegram->shouldReceive('removeWebhook')->once(); + + $this->artisan('telegram:set-webhook')->expectsOutput('Webhook sent to Telegram.')->assertSuccessful(); + $this->artisan('telegram:set-webhook', ['--remove' => true]) + ->expectsOutput('Webhook removal sent to Telegram.') + ->assertSuccessful(); +}); + +it('generates 4 character quick codes that expire after 30 seconds', function () { + $quickCode = new QuickCode; + $quickCode->user_id = User::factory()->create()->id; + $quickCode->save(); + + expect($quickCode->code)->toMatch('/^[0-9A-Z]{4}$/') + ->and($quickCode->isExpired())->toBeFalse() + ->and(QuickCode::unexpired()->count())->toBe(1); + + $this->travel(30)->seconds(); + expect($quickCode->isExpired())->toBeTrue() + ->and(QuickCode::expired()->count())->toBe(1); + + $this->artisan('model:prune', ['--model' => QuickCode::class])->assertSuccessful(); + expect(QuickCode::count())->toBe(0); +}); diff --git a/tests/Feature/DepartmentTest.php b/tests/Feature/DepartmentTest.php new file mode 100644 index 00000000..1766bf7e --- /dev/null +++ b/tests/Feature/DepartmentTest.php @@ -0,0 +1,105 @@ +withoutVite(); + $this->event = activateEvent(); + $this->admin = userWithRole(Role::Admin); + $this->department = Department::factory()->visible()->create(['event_id' => $this->event->id, 'name' => 'Security']); +}); + +it('lists the departments of an event for managers', function () { + $this->actingAs(userWithRole(Role::Manager)) + ->getJson(route('events.departments.index', $this->event)) + ->assertOk() + ->assertJsonPath('departments.0.name', 'Security'); +}); + +it('lists the departments of the active event for volunteers', function () { + $this->actingAs(userWithRole(Role::Volunteer)) + ->getJson(route('events.departments.index', $this->event)) + ->assertOk() + ->assertJsonCount(1, 'departments'); +}); + +it('renders the department editor', function () { + $this->actingAs($this->admin) + ->get(route('events.departments.index', $this->event)) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('EventCrud') + ->where('event.id', $this->event->id) + ->has('events', 1) + ->where('departments.0.name', 'Security')); +}); + +it('shows departments of the active event to everyone and others to managers', function () { + $old = Department::factory()->create(); + $volunteer = userWithRole(Role::Volunteer); + + $this->actingAs($volunteer)->getJson(route('departments.show', $this->department))->assertOk(); + $this->actingAs($volunteer)->getJson(route('departments.show', $old))->assertForbidden(); + $this->actingAs(userWithRole(Role::Manager))->getJson(route('departments.show', $old))->assertOk(); +}); + +it('lets admins create departments with names unique to the event', function () { + $this->actingAs($this->admin) + ->postJson(route('events.departments.store', $this->event), ['name' => 'Registration', 'hidden' => false]) + ->assertOk() + ->assertJsonPath('department.event_id', $this->event->id); + + $this->actingAs($this->admin) + ->postJson(route('events.departments.store', $this->event), ['name' => 'Security', 'hidden' => false]) + ->assertUnprocessable() + ->assertJsonValidationErrors('name'); + + // The same name can be used in another event + $this->actingAs($this->admin) + ->postJson(route('events.departments.store', Event::factory()->create()), ['name' => 'Security', 'hidden' => false]) + ->assertOk(); +}); + +it('requires the hidden flag for new departments', function () { + $this->actingAs($this->admin) + ->postJson(route('events.departments.store', $this->event), ['name' => 'Registration']) + ->assertUnprocessable() + ->assertJsonValidationErrors('hidden'); +}); + +it('lets admins update departments, requiring every field for PUT', function () { + $this->actingAs($this->admin) + ->patchJson(route('departments.update', $this->department), ['hidden' => true]) + ->assertOk(); + expect($this->department->fresh()->hidden)->toBeTrue(); + + $this->actingAs($this->admin) + ->putJson(route('departments.update', $this->department), ['hidden' => false]) + ->assertUnprocessable() + ->assertJsonValidationErrors('name'); +}); + +it('lets admins delete departments', function () { + $this->actingAs($this->admin) + ->deleteJson(route('departments.destroy', $this->department)) + ->assertStatus(205); + + expect($this->department->fresh()->trashed())->toBeTrue() + ->and($this->department->fresh()->display_name)->toBe('Security (del)'); +}); + +it('does not let managers change departments', function () { + $manager = userWithRole(Role::Manager); + + $this->actingAs($manager) + ->postJson(route('events.departments.store', $this->event), ['name' => 'New', 'hidden' => false]) + ->assertForbidden(); + $this->actingAs($manager)->patchJson(route('departments.update', $this->department), ['hidden' => true])->assertForbidden(); + $this->actingAs($manager)->deleteJson(route('departments.destroy', $this->department))->assertForbidden(); +}); diff --git a/tests/Feature/EventTest.php b/tests/Feature/EventTest.php new file mode 100644 index 00000000..ae015685 --- /dev/null +++ b/tests/Feature/EventTest.php @@ -0,0 +1,121 @@ +withoutVite(); + $this->event = activateEvent(Event::factory()->create(['name' => 'BLFC 2026'])); + $this->admin = userWithRole(Role::Admin); +}); + +it('lists events for managers only', function () { + $this->actingAs(userWithRole(Role::Lead))->getJson(route('events.index'))->assertForbidden(); + + $this->actingAs(userWithRole(Role::Manager)) + ->getJson(route('events.index')) + ->assertOk() + ->assertJsonPath('events.0.name', 'BLFC 2026'); +}); + +it('sends the events page to the active event, or lists events when there is none', function () { + $this->actingAs($this->admin) + ->get(route('events.index')) + ->assertRedirect(route('events.departments.index', $this->event)); + + Setting::set('active-event', null); + $this->actingAs($this->admin) + ->get(route('events.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('EventCrud') + ->where('event', null) + ->has('events', 1)); +}); + +it('lets admins create events', function () { + $this->actingAs($this->admin) + ->postJson(route('events.store'), ['name' => 'BLFC 2027']) + ->assertOk() + ->assertJsonPath('event.name', 'BLFC 2027'); + + expect(Event::whereName('BLFC 2027')->exists())->toBeTrue(); +}); + +it('copies the departments of another event when creating one', function () { + Department::factory()->visible()->create(['event_id' => $this->event->id, 'name' => 'Security']); + Department::factory()->hidden()->create(['event_id' => $this->event->id, 'name' => 'Secret']); + + $this->actingAs($this->admin) + ->post(route('events.store'), ['name' => 'BLFC 2027', 'cloneEvent' => $this->event->id]) + ->assertRedirect() + ->assertSessionHas('success', 'Created event BLFC 2027.'); + + $departments = Event::whereName('BLFC 2027')->sole()->departments()->orderBy('name')->get(); + expect($departments->pluck('name')->all())->toBe(['Secret', 'Security']) + ->and($departments->pluck('hidden')->all())->toBe([true, false]) + ->and(Department::count())->toBe(4); +}); + +it('validates new events', function (array $data, string $field) { + $this->actingAs($this->admin) + ->postJson(route('events.store'), $data) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + [['name' => ''], 'name'], + [['name' => 'BLFC 2026'], 'name'], + [['name' => str_repeat('a', 65)], 'name'], + [['name' => 'BLFC 2027', 'cloneEvent' => 'not-a-uuid'], 'cloneEvent'], +]); + +it('allows reusing the name of a deleted event', function () { + $this->event->delete(); + + $this->actingAs($this->admin)->postJson(route('events.store'), ['name' => 'BLFC 2026'])->assertOk(); +}); + +it('does not let managers create, rename, or delete events', function () { + $manager = userWithRole(Role::Manager); + + $this->actingAs($manager)->postJson(route('events.store'), ['name' => 'New'])->assertForbidden(); + $this->actingAs($manager)->patchJson(route('events.update', $this->event), ['name' => 'New'])->assertForbidden(); + $this->actingAs($manager)->deleteJson(route('events.destroy', $this->event))->assertForbidden(); +}); + +it('shows events to volunteers only while they are active', function () { + $volunteer = userWithRole(Role::Volunteer); + $old = Event::factory()->create(); + + $this->actingAs($volunteer)->getJson(route('events.show', $this->event))->assertOk(); + $this->actingAs($volunteer)->getJson(route('events.show', $old))->assertForbidden(); + $this->actingAs($volunteer) + ->get(route('events.show', $this->event)) + ->assertRedirect(route('events.departments.index', $this->event)); +}); + +it('lets admins rename events', function () { + $this->actingAs($this->admin) + ->patchJson(route('events.update', $this->event), ['name' => 'BLFC Renamed']) + ->assertOk(); + + expect($this->event->fresh()->name)->toBe('BLFC Renamed'); +}); + +it('lets admins delete events, clearing the active event', function () { + $this->actingAs($this->admin) + ->delete(route('events.destroy', $this->event)) + ->assertRedirect(route('events.index')) + ->assertSessionHas('success', 'Deleted event BLFC 2026.'); + + TestCase::resetStaticCaches(); + expect($this->event->fresh()->trashed())->toBeTrue() + ->and(Setting::activeEvent())->toBeNull(); +}); diff --git a/tests/Feature/KioskTest.php b/tests/Feature/KioskTest.php new file mode 100644 index 00000000..4d94a937 --- /dev/null +++ b/tests/Feature/KioskTest.php @@ -0,0 +1,87 @@ +lead = userWithRole(Role::Lead); +}); + +it('lets leads authorize their session as a kiosk', function () { + $response = $this->actingAs($this->lead) + ->postJson(route('kiosks.authorize.post')) + ->assertOk(); + + $kiosk = Kiosk::sole(); + $response->assertJsonPath('kiosk.id', $kiosk->id)->assertCookie('kiosk', $kiosk->session_key); + expect(strlen($kiosk->session_key))->toBe(32); +}); + +it('does not let volunteers authorize kiosks', function () { + $this->actingAs(User::factory()->create())->postJson(route('kiosks.authorize.post'))->assertForbidden(); + expect(Kiosk::count())->toBe(0); +}); + +it('does not authorize a session that is already a kiosk', function () { + $kiosk = Kiosk::create(); + + $this->actingAs($this->lead) + ->withCredentials() + ->withCookie('kiosk', $kiosk->session_key) + ->postJson(route('kiosks.authorize.post')) + ->assertJsonPath('error', 'Session is already authorized as a kiosk.'); + + expect(Kiosk::count())->toBe(1); +}); + +it('lets leads deauthorize the kiosk for their session', function () { + $kiosk = Kiosk::create(); + + $this->actingAs($this->lead) + ->withCredentials() + ->withCookie('kiosk', $kiosk->session_key) + ->postJson(route('kiosks.deauthorize.post')) + ->assertStatus(205) + ->assertCookieExpired('kiosk'); + + expect(Kiosk::count())->toBe(0); +}); + +it('does not deauthorize sessions that are not kiosks', function () { + $this->actingAs($this->lead)->postJson(route('kiosks.deauthorize.post'))->assertForbidden(); +}); + +it('recognizes kiosk sessions until they expire', function () { + $kiosk = Kiosk::create(); + request()->cookies->set('kiosk', $kiosk->session_key); + + expect(Kiosk::isSessionAuthorized(true))->toBeTrue(); + + $this->travel(config('tracker.kiosk_lifetime') + 1)->minutes(); + TestCase::resetStaticCaches(); + expect(Kiosk::isSessionAuthorized(true))->toBeFalse() + ->and(Kiosk::expired()->count())->toBe(1); +}); + +it('treats every session as a kiosk in dev mode unless strict', function () { + App\Models\Setting::set('dev-mode', true); + + expect(Kiosk::isSessionAuthorized())->toBeTrue() + ->and(Kiosk::isSessionAuthorized(true))->toBeFalse(); +}); + +it('prunes expired kiosks', function () { + Kiosk::create(); + $this->travel(config('tracker.kiosk_lifetime') + 1)->minutes(); + Kiosk::create(); + + $this->artisan('model:prune', ['--model' => Kiosk::class])->assertSuccessful(); + + expect(Kiosk::count())->toBe(1); +}); diff --git a/tests/Feature/ManagementTest.php b/tests/Feature/ManagementTest.php new file mode 100644 index 00000000..a4cdc672 --- /dev/null +++ b/tests/Feature/ManagementTest.php @@ -0,0 +1,126 @@ +withoutVite(); + $this->event = activateEvent(); + $this->department = Department::factory()->create(['event_id' => $this->event->id]); + $this->admin = userWithRole(Role::Admin); + $this->manager = userWithRole(Role::Manager); +}); + +it('only lets managers into the management panel', function () { + $this->actingAs(userWithRole(Role::Lead))->get(route('management.manage'))->assertForbidden(); + + $this->actingAs($this->manager) + ->get(route('management.manage')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('ManagerDashboard') + ->where('event.id', $this->event->id) + ->where('volunteer', null)); +}); + +it('loads ongoing entries and a volunteer\'s info in the management panel', function () { + $volunteer = User::factory()->create(); + $entry = TimeEntry::factory()->create([ + 'user_id' => $volunteer->id, + 'event_id' => $this->event->id, + 'department_id' => $this->department->id, + 'start' => now()->subHour(), + 'stop' => null, + ]); + + $this->actingAs($this->manager) + ->get(route('management.manage.volunteer', [$this->event, $volunteer])) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('volunteer.user.id', $volunteer->id) + ->loadDeferredProps(fn (AssertableInertia $reload) => $reload + ->where('ongoingEntries.0.id', $entry->id) + ->has('recentTimeActivities', 1))); +}); + +it('only lets admins view reports', function () { + $this->actingAs($this->manager)->get(route('admin.reports'))->assertForbidden(); + $this->actingAs($this->manager)->get(route('admin.reports.view', 'hours'))->assertForbidden(); +}); + +it('sends report pages to the active event', function () { + $this->actingAs($this->admin) + ->get(route('admin.reports')) + ->assertRedirect(route('admin.event.reports', $this->event)); + $this->actingAs($this->admin) + ->get(route('admin.reports.view', 'hours')) + ->assertRedirect(route('admin.event.reports.view', [$this->event, 'hours'])); +}); + +it('lists the reports for an event', function () { + $this->actingAs($this->admin) + ->get(route('admin.event.reports', $this->event)) + ->assertOk() + ->assertViewIs('admin.reports') + ->assertSee('Please select a report to view.'); +}); + +it('asks for an event when there is no active event', function () { + Setting::set('active-event', null); + + $this->actingAs($this->admin) + ->get(route('admin.reports')) + ->assertOk() + ->assertSee('Please select an event to view reports for.'); +}); + +it('renders event reports', function (string $reportType) { + timeEntryFor(User::factory()->create(), $this->department, now()->subDay(), 120, ['auto' => true]); + + $this->actingAs($this->admin) + ->get(route('admin.event.reports.view', [$this->event, $reportType])) + ->assertOk() + ->assertViewIs('admin.report') + ->assertViewHas('reportType', $reportType); +})->with(['hours', 'departments', 'unclocked', 'audit']); + +it('renders an attendee log report from its ID', function () { + $log = new App\Models\AttendeeLog(['name' => 'Lounge']); + $log->event_id = $this->event->id; + $log->save(); + + $this->actingAs($this->admin) + ->get(route('admin.event.reports.view', [$this->event, 'attendee-log', 'id' => $log->id])) + ->assertOk() + ->assertViewHas('report', fn ($report) => $report->attendeeLog->is($log)); +}); + +it('passes the extra parameter to reports', function () { + $this->actingAs($this->admin) + ->get(route('admin.event.reports.view', [$this->event, 'hours', 'hours' => 12])) + ->assertViewHas('report', fn ($report) => $report->hours === 12); +}); + +it('returns 404 for unknown reports and file types', function () { + $this->actingAs($this->admin)->get(route('admin.event.reports.view', [$this->event, 'nope']))->assertNotFound(); + $this->actingAs($this->admin)->get(route('admin.event.reports.export', [$this->event, 'nope', 'csv']))->assertNotFound(); + $this->actingAs($this->admin)->get(route('admin.event.reports.export', [$this->event, 'hours', 'exe']))->assertNotFound(); +}); + +it('downloads reports', function (string $fileType) { + $this->event->update(['name' => 'BLFC 2026']); + timeEntryFor(User::factory()->create(), $this->department, now()->subDay(), 120); + + $response = $this->actingAs($this->admin) + ->get(route('admin.event.reports.export', [$this->event, 'hours', $fileType])) + ->assertOk() + ->assertDownload(); + + expect($response->headers->get('content-disposition'))->toContain('blfc-2026-hours-')->toContain(".{$fileType}"); +})->with(['xlsx', 'csv', 'ods', 'html']); diff --git a/tests/Feature/NotificationTest.php b/tests/Feature/NotificationTest.php new file mode 100644 index 00000000..b19f56bf --- /dev/null +++ b/tests/Feature/NotificationTest.php @@ -0,0 +1,96 @@ +event = activateEvent(); + $this->reward = Reward::factory()->create([ + 'event_id' => $this->event->id, + 'name' => 'Shirt ', + 'description' => 'A & B', + 'hours' => 8, + ]); +}); + +it('lists unread notifications and acknowledges them', function () { + $user = User::factory()->telegramUnlinked()->create(); + $user->notify(new RewardAvailable($this->reward)); + $user->notify(new RewardClaimed($this->reward)); + + $this->actingAs($user) + ->getJson(route('notifications.index')) + ->assertOk() + ->assertJsonCount(2, 'notifications') + ->assertJsonPath('notifications.0.reward_id', $this->reward->id); + + $this->actingAs($user)->postJson(route('notifications.acknowledge'))->assertStatus(205); + + expect($user->unreadNotifications()->count())->toBe(0); + $this->actingAs($user->fresh())->getJson(route('notifications.index'))->assertJsonCount(0, 'notifications'); +}); + +it('redirects home after acknowledging notifications from the page', function () { + $this->actingAs(User::factory()->create()) + ->post(route('notifications.acknowledge')) + ->assertRedirect(route('volunteer.index')); +}); + +it('escapes reward details in notifications', function () { + $user = User::factory()->create(); + $available = (new RewardAvailable($this->reward))->toArray($user); + $claimed = (new RewardClaimed($this->reward))->toArray($user); + + expect($available['description'])->toContain('8hr reward (Shirt <b>)') + ->toContain('A & B') + ->and($available['type'])->toBe('success') + ->and($claimed['description'])->toContain('(Shirt <b>)') + ->and($claimed['type'])->toBe('info'); +}); + +it('describes the time credited for auto-stopped time entries', function () { + $entry = TimeEntry::factory()->create(['start' => now()->subHours(3), 'stop' => now()->subHours(2)]); + $notification = new TimeEntryAutoStopped($entry); + + expect($notification->getCredited())->toBe('1 hour') + ->and($notification->toArray($entry->user)['description'])->toContain('1 hour') + ->and($notification->toTelegram($entry->user)['text'])->toContain('1 hour'); +}); + +it('sends notifications through Telegram to users with a linked chat', function () { + $user = User::factory()->create(['tg_chat_id' => 5555]); + fakeTelegram()->shouldReceive('sendMessage')->once()->with(Mockery::on( + fn (array $message) => $message['chat_id'] === 5555 && str_contains($message['text'], '8hr reward'), + )); + + (new TelegramChannel)->send($user, new RewardAvailable($this->reward)); +}); + +it('skips Telegram for users without a linked chat', function () { + $user = User::factory()->telegramUnlinked()->create(); + fakeTelegram()->shouldReceive('sendMessage')->never(); + + (new TelegramChannel)->send($user, new RewardAvailable($this->reward)); +}); + +it('stores notifications in the database and sends them to Telegram', function (string $class, mixed $subject) { + $user = User::factory()->telegramLinked()->create(); + fakeTelegram()->shouldReceive('sendMessage')->once(); + + $subject = $subject === 'reward' ? $this->reward : TimeEntry::factory()->create(['user_id' => $user->id]); + $user->notify(new $class($subject)); + + expect($user->notifications()->sole()->type)->toBe($class); +})->with([ + [RewardAvailable::class, 'reward'], + [RewardClaimed::class, 'reward'], + [TimeEntryAutoStopped::class, 'entry'], +]); diff --git a/tests/Feature/ReportsTest.php b/tests/Feature/ReportsTest.php new file mode 100644 index 00000000..5178758d --- /dev/null +++ b/tests/Feature/ReportsTest.php @@ -0,0 +1,210 @@ +event = activateEvent(App\Models\Event::factory()->create(['name' => 'BLFC 2026'])); + $this->security = Department::factory()->visible()->create(['event_id' => $this->event->id, 'name' => 'Security']); + $this->registration = Department::factory()->create(['event_id' => $this->event->id, 'name' => 'Registration']); + $this->foxy = User::factory()->create(['badge_id' => 1, 'username' => 'foxy']); + $this->wolfy = User::factory()->create(['badge_id' => 2, 'username' => 'wolfy']); + $start = now()->subDays(2)->startOfHour(); + timeEntryFor($this->foxy, $this->security, $start, 120); + timeEntryFor($this->foxy, $this->registration, $start->avoidMutation()->addHours(3), 60); + timeEntryFor($this->wolfy, $this->security, $start, 30, ['auto' => true]); + // Time for other events isn't counted + timeEntryFor($this->wolfy, Department::factory()->create(), $start, 600); + + $bonus = TimeBonus::factory()->create([ + 'event_id' => $this->event->id, + 'start' => $start, + 'stop' => $start->avoidMutation()->addHour(), + 'modifier' => 2, + ]); + $bonus->departments()->attach($this->security); +}); + +/** + * Builds a fake ConCat volunteer application + */ +function fakeVolunteer(int $id, array $departments, int $hours, array $extra = []): stdClass { + return json_decode(json_encode([ + 'user' => [ + 'id' => $id, + 'username' => "user{$id}", + 'preferredName' => "Pref {$id}", + 'firstName' => 'First', + 'lastName' => "Last{$id}", + 'isStaff' => false, + 'email' => "user{$id}@example.com", + 'phone' => '555-0100', + ], + 'departments' => $departments, + 'contactMethods' => [['name' => 'telegram', 'value' => "@user{$id}", 'isPrimary' => true]], + 'options' => [ + ['name' => VolunteerApplicationDepartmentSummaryReport::AVAILABLE_HOURS_OPTION, 'value' => $hours], + ['name' => 'Volunteer Days', 'value' => ['Friday', 'Saturday']], + ['name' => 'Have you previously volunteered with BLFC 2026? If so, what did you do? [Optional]', 'value' => 'Lots'], + ], + 'createdAt' => '2026-01-01T00:00:00Z', + 'updatedAt' => '2026-01-02T00:00:00Z', + ...$extra, + ])); +} + +/** + * Builds a fake department entry of a ConCat volunteer application + */ +function fakeApplicationDepartment(int $id, string $name, array $states): array { + return ['id' => $id, 'name' => $name, 'states' => $states]; +} + +it('reports hours worked and earned per volunteer for the event', function () { + $data = (new VolunteerTimeReport($this->event, 0))->toCollection(); + + expect($data['head']->all())->toBe(['Badge Number', 'Username', 'Hours Worked', 'Hours Earned', 'Departments']) + ->and($data['body']->values()->all())->toEqual([ + [1, 'foxy', 3.0, 4.0, 'Registration, Security'], + [2, 'wolfy', 0.5, 1.0, 'Security'], + ]) + ->and($data['totals']->all())->toEqual(['Totals', null, 3.5, 5.0, null]); +}); + +it('only reports volunteers above the hour threshold', function () { + $data = (new VolunteerTimeReport($this->event, 2))->toCollection(); + + expect($data['body']->pluck(0)->values()->all())->toBe([1]); +}); + +it('summarizes hours, volunteers, and entries per department', function () { + $data = (new DepartmentSummaryReport($this->event))->toCollection(); + + expect($data['body']->all())->toEqual([ + ['Registration', 1.0, 1, 1], + ['Security', 2.5, 2, 2], + ]) + ->and($data['totals']->all())->toEqual(['Totals', 3.5, 2, 3, null]); +}); + +it('lists automatically stopped time entries', function () { + $report = new AutoClosedTimeEntriesReport($this->event); + $rows = $report->toCollection()['body']; + + expect($rows)->toHaveCount(1) + ->and($rows[0][0])->toBe(2) + ->and($rows[0][4])->toBe('Security') + ->and($report->filename('csv'))->toStartWith('blfc-2026-unclocked-')->toEndWith('.csv') + ->and($report->properties())->toMatchArray(['title' => 'Unclocked Users', 'description' => 'BLFC 2026 unclocked users']); +}); + +it('reports changes made by users to other entities', function () { + $admin = userWithRole(Role::Admin, ['badge_name' => 'Admin', 'badge_id' => 99]); + Activity::query()->delete(); + $this->actingAs($admin); + $this->security->update(['name' => 'Safety', 'hidden' => true]); + + // Volunteers changing their own time entries aren't reported + $this->actingAs($this->foxy); + $this->foxy->timeEntries()->first()->update(['notes' => 'Changed']); + + $rows = (new AuditLogReport(0))->toCollection()['body']->values(); + + expect($rows)->toHaveCount(1) + ->and($rows[0][1])->toBe('Admin (#99)') + ->and($rows[0][2])->toBe('Department') + ->and($rows[0][3])->toBe('Safety') + ->and($rows[0][4])->toBe('- Event: BLFC 2026') + ->and($rows[0][5])->toBe('updated') + ->and($rows[0][6])->toContain('- name: Security → Safety') + ->toContain('- hidden: false → true'); +}); + +it('limits the audit log to recent days', function () { + $admin = userWithRole(Role::Admin); + Activity::query()->delete(); + $this->actingAs($admin); + $this->security->update(['name' => 'Safety']); + Activity::query()->update(['created_at' => now()->subDays(10)]); + + expect((new AuditLogReport(7))->toCollection()['body'])->toHaveCount(0) + ->and((new AuditLogReport(30))->toCollection()['body'])->toHaveCount(1); +}); + +it('reports volunteer applications from ConCat', function () { + ConCat::shouldReceive('isAuthorized')->andReturn(false); + ConCat::shouldReceive('authorize')->once(); + ConCat::shouldReceive('searchVolunteers')->once()->andReturn([ + fakeVolunteer(10, [fakeApplicationDepartment(1, 'Security', ['assignment', 'interest'])], 20), + fakeVolunteer(11, [fakeApplicationDepartment(2, 'Art Show', ['avoid'])], 8), + ]); + ConCat::shouldReceive('searchRegistrationsByUserIds')->once()->with(['10', '11'])->andReturn([ + (object) ['badgeName' => 'Foxy', 'user' => (object) ['id' => 10]], + ]); + + $report = new VolunteerApplicationsReport($this->event); + $report->prefetch(); + $data = $report->toCollection(); + $foxy = $data['body'][10]; + + expect($data['head']->slice(-2)->values()->all())->toBe(['Art Show', 'Security']) + ->and($foxy[1])->toBe('Foxy') + ->and($foxy[4])->toBe('First Last10') + ->and($foxy[6])->toBe('Security') + ->and($foxy[7])->toBe('telegram') + ->and($foxy[10])->toBe('@user10') + ->and($foxy[16])->toBe('20') + ->and(array_slice($foxy, 17, 7))->toBe([null, null, 'F', 'Sa', null, null, null]) + ->and($foxy[26])->toBe('Lots') + ->and(array_slice($foxy, -2))->toBe([null, '✔️❤️']) + ->and($data['body'][11][1])->toBe('user11') + ->and(array_slice($data['body'][11], -2))->toBe(['❌', null]); +}); + +it('summarizes volunteer applications per department, splitting hours', function () { + ConCat::shouldReceive('isAuthorized')->andReturn(true); + ConCat::shouldReceive('searchVolunteers')->once()->andReturn([ + // Assigned to two departments, so only they count and get half the hours each + fakeVolunteer(10, [ + fakeApplicationDepartment(1, 'Security', ['assignment']), + fakeApplicationDepartment(2, 'Art Show', ['assignment']), + fakeApplicationDepartment(3, 'Dance', ['interest']), + ], 20), + // Unassigned, so departments they're interested in count, except ones to avoid + fakeVolunteer(11, [ + fakeApplicationDepartment(1, 'Security', ['interest']), + fakeApplicationDepartment(3, 'Dance', ['experience']), + fakeApplicationDepartment(2, 'Art Show', ['avoid']), + ], 8), + ]); + + $data = (new VolunteerApplicationDepartmentSummaryReport)->toCollection(); + + expect($data['body']->map(fn ($row) => array_values($row))->all())->toEqual([ + ['Art Show', 1, 10.0, 0, 0.0, 1, 10.0], + ['Dance', 0, 0.0, 1, 4.0, 1, 4.0], + ['Security', 1, 10.0, 1, 4.0, 2, 14.0], + ]) + ->and($data['totals']->all())->toEqual(['Totals', 2, 20.0, 2, 8.0, 4, 28.0]); +}); + +it('handles having no volunteer applications', function () { + ConCat::shouldReceive('isAuthorized')->andReturn(true); + ConCat::shouldReceive('searchVolunteers')->twice()->andReturn([]); + + expect((new VolunteerApplicationsReport($this->event))->toCollection()['body'])->toBeEmpty() + ->and((new VolunteerApplicationDepartmentSummaryReport)->toCollection()['body'])->toBeEmpty(); +}); diff --git a/tests/Feature/RewardTest.php b/tests/Feature/RewardTest.php new file mode 100644 index 00000000..92d54d53 --- /dev/null +++ b/tests/Feature/RewardTest.php @@ -0,0 +1,140 @@ +event = activateEvent(); + $this->admin = userWithRole(Role::Admin); + $this->manager = userWithRole(Role::Manager); + $this->reward = Reward::factory()->create(['event_id' => $this->event->id, 'name' => 'Shirt', 'hours' => 8]); +}); + +it('lists the rewards of an event', function () { + $this->actingAs(userWithRole(Role::Volunteer)) + ->getJson(route('events.rewards.index', $this->event)) + ->assertOk() + ->assertJsonPath('rewards.0.name', 'Shirt'); +}); + +it('lets admins create rewards with names unique to the event', function () { + $this->actingAs($this->admin) + ->postJson(route('events.rewards.store', $this->event), ['name' => 'Hoodie', 'description' => 'Warm', 'hours' => 16]) + ->assertOk() + ->assertJsonPath('reward.event_id', $this->event->id); + + $this->actingAs($this->admin) + ->postJson(route('events.rewards.store', $this->event), ['name' => 'Shirt', 'description' => 'Again', 'hours' => 4]) + ->assertUnprocessable() + ->assertJsonValidationErrors('name'); +}); + +it('validates reward hours', function (mixed $hours) { + $this->actingAs($this->admin) + ->postJson(route('events.rewards.store', $this->event), ['name' => 'Hoodie', 'description' => 'Warm', 'hours' => $hours]) + ->assertUnprocessable() + ->assertJsonValidationErrors('hours'); +})->with([-1, 169, 1.5, null]); + +it('lets admins update and delete rewards', function () { + $this->actingAs($this->admin) + ->patchJson(route('rewards.update', $this->reward), ['hours' => 10]) + ->assertOk(); + expect($this->reward->fresh()->hours)->toEqual(10); + + $this->actingAs($this->admin)->deleteJson(route('rewards.destroy', $this->reward))->assertStatus(205); + expect($this->reward->fresh()->trashed())->toBeTrue(); +}); + +it('does not let managers change rewards', function () { + $this->actingAs($this->manager) + ->postJson(route('events.rewards.store', $this->event), ['name' => 'Hoodie', 'description' => 'Warm', 'hours' => 16]) + ->assertForbidden(); + $this->actingAs($this->manager)->patchJson(route('rewards.update', $this->reward), ['hours' => 1])->assertForbidden(); + $this->actingAs($this->manager)->deleteJson(route('rewards.destroy', $this->reward))->assertForbidden(); +}); + +it('shows rewards of the active event to volunteers', function () { + $volunteer = userWithRole(Role::Volunteer); + $this->actingAs($volunteer)->getJson(route('rewards.show', $this->reward))->assertOk(); + $this->actingAs($volunteer)->getJson(route('rewards.show', Reward::factory()->create()))->assertForbidden(); +}); + +it('lets managers claim rewards of the active event for volunteers', function () { + $volunteer = User::factory()->create(); + + $this->actingAs($this->manager) + ->putJson(route('users.claims.store', $volunteer), ['reward_id' => $this->reward->id]) + ->assertOk() + ->assertJsonPath('reward_claim.reward_id', $this->reward->id); + + $this->actingAs($this->manager) + ->putJson(route('users.claims.store', $volunteer), ['reward_id' => $this->reward->id]) + ->assertUnprocessable() + ->assertJsonPath('error', 'Reward is already claimed by user.'); + + expect($volunteer->rewardClaims()->count())->toBe(1); +}); + +it('only lets admins claim rewards of other events', function () { + $volunteer = User::factory()->create(); + $oldReward = Reward::factory()->create(); + + $this->actingAs($this->manager) + ->putJson(route('users.claims.store', $volunteer), ['reward_id' => $oldReward->id]) + ->assertForbidden(); + $this->actingAs($this->admin) + ->putJson(route('users.claims.store', $volunteer), ['reward_id' => $oldReward->id]) + ->assertOk(); +}); + +it('does not let volunteers claim rewards', function () { + $volunteer = User::factory()->create(); + + $this->actingAs($volunteer) + ->putJson(route('users.claims.store', $volunteer), ['reward_id' => $this->reward->id]) + ->assertForbidden(); +}); + +it('lets volunteers view their own claims and managers view anyone\'s', function () { + $volunteer = User::factory()->create(); + $claim = new RewardClaim; + $claim->user_id = $volunteer->id; + $claim->reward_id = $this->reward->id; + $claim->save(); + + $this->actingAs($volunteer) + ->getJson(route('users.claims', $volunteer)) + ->assertOk() + ->assertJsonPath('reward_claims.0.id', $claim->id); + $this->actingAs(User::factory()->create())->getJson(route('users.claims', $volunteer))->assertForbidden(); + $this->actingAs($this->manager)->getJson(route('users.claims', $volunteer))->assertOk(); + + // Claims are filtered by event + $this->actingAs($this->manager) + ->getJson(route('users.claims.event', [$volunteer, Event::factory()->create()])) + ->assertJsonCount(0, 'reward_claims'); +}); + +it('lets managers unclaim rewards of the active event only', function () { + $volunteer = User::factory()->create(); + $claims = collect([$this->reward, Reward::factory()->create()])->map(function (Reward $reward) use ($volunteer) { + $claim = new RewardClaim; + $claim->user_id = $volunteer->id; + $claim->reward_id = $reward->id; + $claim->save(); + return $claim; + }); + + $this->actingAs($this->manager)->deleteJson(route('claims.destroy', $claims[1]))->assertForbidden(); + $this->actingAs($this->manager)->deleteJson(route('claims.destroy', $claims[0]))->assertStatus(205); + $this->actingAs($this->admin)->deleteJson(route('claims.destroy', $claims[1]))->assertStatus(205); + + expect(RewardClaim::count())->toBe(0); +}); diff --git a/tests/Feature/SettingTest.php b/tests/Feature/SettingTest.php new file mode 100644 index 00000000..4322ad04 --- /dev/null +++ b/tests/Feature/SettingTest.php @@ -0,0 +1,127 @@ +event = activateEvent(); + $this->admin = userWithRole(Role::Admin); +}); + +it('only lets admins view the settings', function () { + $this->actingAs(userWithRole(Role::Manager))->getJson(route('settings.index'))->assertForbidden(); + + $this->actingAs($this->admin) + ->getJson(route('settings.index')) + ->assertOk() + ->assertJsonCount(3, 'settings') + ->assertJsonPath('events.0.id', $this->event->id); +}); + +it('changes the active event', function () { + $other = Event::factory()->create(); + + $this->actingAs($this->admin) + ->from(route('settings.index')) + ->put(route('settings.update', 'active-event'), ['value' => $other->id]) + ->assertRedirect(route('settings.index')) + ->assertSessionHas('success', 'Updated the active event.'); + + TestCase::resetStaticCaches(); + expect(Setting::activeEvent()->id)->toBe($other->id); +}); + +it('clears the active event', function () { + $this->actingAs($this->admin) + ->putJson(route('settings.update', 'active-event'), ['value' => null]) + ->assertStatus(205); + + TestCase::resetStaticCaches(); + expect(Setting::activeEvent())->toBeNull(); +}); + +it('rejects active events that do not exist', function () { + $this->actingAs($this->admin) + ->putJson(route('settings.update', 'active-event'), ['value' => fake()->uuid()]) + ->assertUnprocessable() + ->assertJsonValidationErrors('value'); +}); + +it('toggles dev mode and the lockdown', function (string $setting, string $getter, string $on, string $off) { + $this->actingAs($this->admin) + ->from(route('settings.index')) + ->put(route('settings.update', $setting), ['value' => '1']) + ->assertSessionHas('success', $on); + TestCase::resetStaticCaches(); + expect(Setting::$getter())->toBeTrue(); + + $this->actingAs($this->admin) + ->from(route('settings.index')) + ->put(route('settings.update', $setting), ['value' => '0']) + ->assertSessionHas('success', $off); + TestCase::resetStaticCaches(); + expect(Setting::$getter())->toBeFalse(); +})->with([ + ['dev-mode', 'isDevMode', 'Enabled development mode.', 'Disabled development mode.'], + ['lockdown', 'isLockedDown', 'Locked the site down.', 'Lifted the lockdown.'], +]); + +it('requires a value when updating a setting', function () { + $this->actingAs($this->admin) + ->putJson(route('settings.update', 'lockdown'), []) + ->assertUnprocessable() + ->assertJsonValidationErrors('value'); +}); + +it('clears the value of a setting', function () { + $this->actingAs($this->admin) + ->deleteJson(route('settings.destroy', 'lockdown')) + ->assertStatus(205); + + expect(Setting::whereName('lockdown')->sole()->value)->toBeNull(); + TestCase::resetStaticCaches(); + expect(Setting::isLockedDown())->toBeFalse(); +}); + +it('does not let managers change settings', function () { + $manager = userWithRole(Role::Manager); + + $this->actingAs($manager)->putJson(route('settings.update', 'lockdown'), ['value' => true])->assertForbidden(); + $this->actingAs($manager)->deleteJson(route('settings.destroy', 'lockdown'))->assertForbidden(); +}); + +it('refuses to set unknown settings', function () { + Setting::set('made-up', true); +})->throws(ValueError::class, 'Unknown setting name: made-up'); + +it('caches setting values until they are changed', function () { + expect(Setting::isLockedDown())->toBeFalse(); + + // Changes made directly in the database aren't seen while the value is cached + Setting::whereName('lockdown')->update(['value' => 'true']); + expect(Setting::isLockedDown())->toBeFalse(); + + // The value is cached in memory and in the cache store + TestCase::resetStaticCaches(); + expect(Setting::isLockedDown())->toBeFalse(); + TestCase::resetStaticCaches(); + Cache::forget('setting:lockdown'); + expect(Setting::isLockedDown())->toBeTrue(); + + Setting::set('lockdown', false); + expect(Setting::isLockedDown())->toBeFalse(); +}); + +it('clears the active event when its event is deleted', function () { + $this->event->delete(); + + TestCase::resetStaticCaches(); + expect(Setting::activeEvent())->toBeNull() + ->and($this->event->isActive())->toBeFalse(); +}); diff --git a/tests/Feature/SharedPageDataTest.php b/tests/Feature/SharedPageDataTest.php new file mode 100644 index 00000000..68e25355 --- /dev/null +++ b/tests/Feature/SharedPageDataTest.php @@ -0,0 +1,77 @@ +withoutVite(); + $this->event = activateEvent(App\Models\Event::factory()->create(['name' => 'BLFC 2026'])); + fakeTelegram()->shouldReceive('getMe')->andReturn((object) ['username' => 'TrackerBot']); +}); + +it('shares the user, active event, and site state with every page', function () { + $user = User::factory()->create(['badge_name' => 'Foxy']); + + $this->actingAs($user) + ->get(route('volunteer.index')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('auth.user.id', $user->id) + ->where('auth.user.badge_name', 'Foxy') + ->missing('auth.user.first_name') + ->where('activeEvent', ['id' => $this->event->id, 'name' => 'BLFC 2026']) + ->where('timezone', config('tracker.timezone')) + ->where('isGatekeeper', false) + ->where('isDevMode', false) + ->where('isKiosk', false)); +}); + +it('shares whether the user is a gatekeeper and the session is a kiosk', function () { + $user = User::factory()->create(); + $log = new AttendeeLog(['name' => 'Lounge']); + $log->event_id = $this->event->id; + $log->save(); + $log->users()->attach($user, ['type' => 'gatekeeper']); + $kiosk = Kiosk::create(); + + $this->actingAs($user) + ->withCookie('kiosk', $kiosk->session_key) + ->get(route('volunteer.index')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('isGatekeeper', true) + ->where('isKiosk', true)); +}); + +it('shares flash messages', function () { + $this->actingAs(userWithRole(Role::Admin)) + ->withSession(['success' => 'It worked.']) + ->get(route('volunteer.index')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('flash.success', 'It worked.') + ->where('flash.error', null)); +}); + +it('logs logins, logouts, and quick code use', function () { + $messages = collect(); + EventFacade::listen(MessageLogged::class, fn (MessageLogged $log) => $messages->push($log->message)); + $user = User::factory()->create(); + $quickCode = new QuickCode; + $quickCode->user_id = $user->id; + $quickCode->save(); + + Auth::login($user); + Auth::logout(); + QuickCodeLogin::dispatch($quickCode); + + expect($messages->all())->toContain('User logged in', 'User logged out', 'Quick code used'); +}); diff --git a/tests/Feature/TelegramBotTest.php b/tests/Feature/TelegramBotTest.php new file mode 100644 index 00000000..0d165592 --- /dev/null +++ b/tests/Feature/TelegramBotTest.php @@ -0,0 +1,228 @@ +event = activateEvent(App\Models\Event::factory()->create(['name' => 'BLFC 2026'])); + $this->user = User::factory()->create(['badge_name' => 'Foxy', 'badge_id' => 1234, 'tg_chat_id' => 5555]); + + // Collect every message the bot sends + $this->sent = collect(); + $this->api = Mockery::mock(Api::class); + $this->api->shouldReceive('sendMessage', 'sendSticker')->andReturnUsing(function (array $params) { + $this->sent->push($params); + return new Message([]); + }); + $this->api->shouldReceive('getCommands')->andReturn(collect([ + new HelpCommand, + new HoursCommand, + new LinkCommand, + new PlzHelpCommand, + new QuickCodeCommand, + new RewardsCommand, + new UnlinkCommand, + ])->keyBy(fn (Command $command) => $command->getName())->all()); +}); + +/** + * Runs a bot command as if it was sent in a chat, returning the text of the bot's last reply + */ +function sendTelegramCommand(Command $command, string $text, int $chatId = 5555): ?string { + $test = test(); + $commandLength = strlen(explode(' ', $text)[0]); + $entity = ['type' => 'bot_command', 'offset' => 0, 'length' => $commandLength]; + $update = new Update([ + 'update_id' => 1, + 'message' => [ + 'message_id' => 1, + 'date' => 0, + 'chat' => ['id' => $chatId, 'type' => 'private'], + 'text' => $text, + 'entities' => [$entity], + ], + ]); + + $command->make($test->api, $update, $entity); + return $test->sent->last()['text'] ?? null; +} + +it('shows the help for unlinked chats', function () { + $reply = sendTelegramCommand(new HelpCommand, '/help', 9999); + + expect($reply)->toContain("Ongoing event:\nBLFC 2026") + ->toContain("You haven't linked a volunteer account to me.") + ->toContain('/link - Link your volunteer account') + ->toContain('/help - ') + ->not->toContain('/hours') + ->not->toContain('/plzhelp'); +}); + +it('shows the help for linked chats', function () { + Setting::set('active-event', null); + + $reply = sendTelegramCommand(new HelpCommand, '/help'); + + expect($reply)->toContain('None 😔') + ->toContain('Foxy (#1234)') + ->toContain('/hours - Show hours clocked') + ->not->toContain('/link'); +}); + +it('links a chat to a volunteer account with its setup key, then changes the key', function () { + $user = User::factory()->telegramUnlinked()->create(['badge_name' => 'Wolfy']); + $key = $user->tg_setup_key; + + $reply = sendTelegramCommand(new LinkCommand, "/start {$key}", 7777); + + $user->refresh(); + expect($reply)->toContain('Thanks for volunteering, Wolfy!') + ->and($user->tg_chat_id)->toBe(7777) + ->and($user->tg_setup_key)->not->toBe($key); +}); + +it('tells the old chat when a volunteer account is linked to a new one', function () { + sendTelegramCommand(new LinkCommand, "/link {$this->user->tg_setup_key}", 7777); + + expect($this->sent)->toHaveCount(2) + ->and($this->sent[0]['chat_id'])->toBe(5555) + ->and($this->sent[0]['text'])->toContain('changed to report to another user') + ->and($this->user->fresh()->tg_chat_id)->toBe(7777); +}); + +it('still links a new chat when the old chat cannot be reached', function () { + $api = Mockery::mock(Api::class); + $api->shouldReceive('sendMessage')->once()->andThrow(new TelegramSDKException('Chat not found')); + $api->shouldReceive('sendMessage')->once()->andReturn(new Message([])); + $this->api = $api; + + sendTelegramCommand(new LinkCommand, "/link {$this->user->tg_setup_key}", 7777); + + expect($this->user->fresh()->tg_chat_id)->toBe(7777); +}); + +it('refuses to link without a valid setup key or from a chat that is already linked', function () { + expect(sendTelegramCommand(new LinkCommand, '/link', 7777))->toContain("setup key for your volunteer account wasn't provided") + ->and(sendTelegramCommand(new LinkCommand, '/link ' . str_repeat('a', 32), 7777))->toContain('Unable to validate') + ->and(sendTelegramCommand(new LinkCommand, '/link ' . str_repeat('a', 32)))->toContain("I'm already familiar with you"); +}); + +it('unlinks chats, even for banned volunteers', function () { + $this->user->update(['role' => Role::Banned]); + + expect(sendTelegramCommand(new UnlinkCommand, '/unlink'))->toContain('Your volunteer account has been unlinked.') + ->and($this->user->fresh()->tg_chat_id)->toBeNull(); +}); + +it('asks unlinked chats to link an account first', function () { + expect(sendTelegramCommand(new HoursCommand, '/hours', 9999))->toContain("I don't have a volunteer account associated with you yet."); +}); + +it('refuses banned volunteers', function () { + $this->user->update(['role' => Role::Banned]); + + expect(sendTelegramCommand(new HoursCommand, '/hours'))->toContain('There is a hold on your volunteer account.'); +}); + +it('needs an active event for hours and rewards', function () { + Setting::set('active-event', null); + + expect(sendTelegramCommand(new HoursCommand, '/hours'))->toContain("There isn't any ongoing event right now.") + ->and(sendTelegramCommand(new RewardsCommand, '/rewards'))->toContain("There isn't any ongoing event right now."); +}); + +it('shows the hours of a volunteer, including an ongoing shift', function () { + $department = Department::factory()->create(['event_id' => $this->event->id]); + timeEntryFor($this->user, $department, now()->subDays(2), 150); + App\Models\TimeEntry::factory()->create([ + 'user_id' => $this->user->id, + 'event_id' => $this->event->id, + 'department_id' => $department->id, + 'start' => now()->subMinutes(30), + 'stop' => null, + ]); + + $reply = sendTelegramCommand(new HoursCommand, '/hours'); + + expect($reply)->toContain('BLFC 2026') + ->toContain("You're currently clocked in!") + ->toContain('Shift time: 30m') + ->toContain('Total time earned: 3h'); +}); + +it('gives volunteers a quick sign-in code, replacing their old one', function () { + EventFacade::fake([QuickCodeGenerated::class]); + + $first = sendTelegramCommand(new QuickCodeCommand, '/code'); + $code = QuickCode::sole()->code; + expect($first)->toContain("{$code}")->not->toContain('old code'); + + $second = sendTelegramCommand(new QuickCodeCommand, '/code'); + expect($second)->toContain('Your old code was invalidated.') + ->and(QuickCode::count())->toBe(1); + EventFacade::assertDispatchedTimes(QuickCodeGenerated::class, 2); +}); + +it('does not give quick sign-in codes to leads and above', function () { + $this->user->update(['role' => Role::Lead]); + + expect(sendTelegramCommand(new QuickCodeCommand, '/code'))->toContain('staff may not use quick sign-in codes') + ->and(QuickCode::count())->toBe(0); +}); + +it('lists rewards with whether they are claimed, available, or locked', function () { + $department = Department::factory()->create(['event_id' => $this->event->id]); + timeEntryFor($this->user, $department, now()->subDays(2), 9 * 60); + $claimed = Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 2, 'name' => 'Pin']); + Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 8, 'name' => 'Shirt']); + Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 12, 'name' => 'Hoodie & Hat']); + $claim = new RewardClaim; + $claim->user_id = $this->user->id; + $claim->reward_id = $claimed->id; + $claim->save(); + + $reply = sendTelegramCommand(new RewardsCommand, '/rewards'); + + expect($reply)->toContain('✅ 2hr: Pin') + ->toContain('⭐ 8hr: Shirt') + ->toContain('⏳ 12hr: Hoodie & Hat'); +}); + +it('says when an event has no rewards', function () { + expect(sendTelegramCommand(new RewardsCommand, '/rewards'))->toBe('There are no rewards available for BLFC 2026.'); +}); + +it('replies to pleas for help with stickers', function () { + sendTelegramCommand(new PlzHelpCommand, '/plzhelp'); + + expect($this->sent)->toHaveCount(2) + ->and($this->sent->pluck('sticker')->filter())->toHaveCount(2); +}); + +it('passes webhook updates to the command handler', function () { + fakeTelegram()->shouldReceive('commandsHandler')->once()->with(true); + + $this->post(route('telegram.tracker.webhook'))->assertOk()->assertSee('ok'); +}); diff --git a/tests/Feature/TimeBonusTest.php b/tests/Feature/TimeBonusTest.php new file mode 100644 index 00000000..a9d367f5 --- /dev/null +++ b/tests/Feature/TimeBonusTest.php @@ -0,0 +1,132 @@ +event = activateEvent(); + $this->admin = userWithRole(Role::Admin); + $this->departments = Department::factory()->count(2)->create(['event_id' => $this->event->id]); +}); + +/** + * Builds valid input for a time bonus + */ +function timeBonusInput(array $departmentIds, array $overrides = []): array { + return [ + 'start' => '2026-07-01T10:00:00-07:00', + 'stop' => '2026-07-01T14:00:00-07:00', + 'modifier' => 1.5, + 'departments' => $departmentIds, + ...$overrides, + ]; +} + +it('lets admins create time bonuses for departments, storing times in UTC', function () { + $this->actingAs($this->admin) + ->postJson(route('events.bonuses.store', $this->event), timeBonusInput($this->departments->pluck('id')->all())) + ->assertOk(); + + $bonus = TimeBonus::sole(); + expect($bonus->start->equalTo(Carbon::parse('2026-07-01T17:00:00Z')))->toBeTrue() + ->and($bonus->getRawOriginal('start'))->toStartWith('2026-07-01 17:00:00') + ->and($bonus->modifier)->toBe(1.5) + ->and($bonus->departments()->count())->toBe(2); +}); + +it('validates new time bonuses', function (array $overrides, string $field) { + $this->actingAs($this->admin) + ->postJson(route('events.bonuses.store', $this->event), timeBonusInput([$this->departments[0]->id], $overrides)) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'stop before start' => [['stop' => '2026-07-01T09:00:00-07:00'], 'stop'], + 'modifier below 1' => [['modifier' => 0.5], 'modifier'], + 'modifier above 10' => [['modifier' => 11], 'modifier'], + 'modifier with 3 decimals' => [['modifier' => 1.125], 'modifier'], + 'no departments' => [['departments' => []], 'departments'], +]); + +it('only accepts departments from the same event', function () { + $foreign = Department::factory()->create(['event_id' => Event::factory()->create()->id]); + + $this->actingAs($this->admin) + ->postJson(route('events.bonuses.store', $this->event), timeBonusInput([$foreign->id])) + ->assertUnprocessable() + ->assertJsonValidationErrors('departments'); +}); + +it('lists the time bonuses of an event with their departments', function () { + $bonus = TimeBonus::factory()->create(['event_id' => $this->event->id]); + $bonus->departments()->attach($this->departments[0]); + + $this->actingAs(userWithRole(Role::Manager)) + ->getJson(route('events.bonuses.index', $this->event)) + ->assertOk() + ->assertJsonPath('bonuses.0.departments.0.id', $this->departments[0]->id); +}); + +it('updates time bonuses and replaces their departments', function () { + $bonus = TimeBonus::factory()->create(['event_id' => $this->event->id, 'modifier' => 2]); + $bonus->departments()->attach($this->departments[0]); + + $this->actingAs($this->admin) + ->patchJson(route('bonuses.update', $bonus), ['modifier' => 3, 'departments' => [$this->departments[1]->id]]) + ->assertOk(); + + $bonus->refresh(); + expect($bonus->modifier)->toBe(3.0) + ->and($bonus->departments->pluck('id')->all())->toBe([$this->departments[1]->id]); +}); + +it('keeps the departments of time bonuses when not given', function () { + $bonus = TimeBonus::factory()->create(['event_id' => $this->event->id]); + $bonus->departments()->attach($this->departments[0]); + + $this->actingAs($this->admin) + ->patchJson(route('bonuses.update', $bonus), ['stop' => '2030-01-01T00:00:00Z']) + ->assertOk(); + + expect($bonus->departments()->count())->toBe(1); +}); + +it('lets admins delete time bonuses', function () { + $bonus = TimeBonus::factory()->create(['event_id' => $this->event->id]); + + $this->actingAs($this->admin)->deleteJson(route('bonuses.destroy', $bonus))->assertStatus(205); + + expect(TimeBonus::count())->toBe(0); +}); + +it('does not let managers change time bonuses', function () { + $manager = userWithRole(Role::Manager); + $bonus = TimeBonus::factory()->create(['event_id' => $this->event->id]); + + $this->actingAs($manager) + ->postJson(route('events.bonuses.store', $this->event), timeBonusInput([$this->departments[0]->id])) + ->assertForbidden(); + $this->actingAs($manager)->patchJson(route('bonuses.update', $bonus), ['modifier' => 3])->assertForbidden(); + $this->actingAs($manager)->deleteJson(route('bonuses.destroy', $bonus))->assertForbidden(); +}); + +it('lists time bonuses of the active event only for volunteers', function () { + $volunteer = userWithRole(Role::Volunteer); + + $this->actingAs($volunteer)->getJson(route('events.bonuses.index', $this->event))->assertOk(); + $this->actingAs($volunteer)->getJson(route('events.bonuses.index', Event::factory()->create()))->assertForbidden(); +}); + +it('shows time bonuses of the active event to volunteers', function () { + $volunteer = userWithRole(Role::Volunteer); + $bonus = TimeBonus::factory()->create(['event_id' => $this->event->id]); + $old = TimeBonus::factory()->create(); + + $this->actingAs($volunteer)->getJson(route('bonuses.show', $bonus))->assertOk(); + $this->actingAs($volunteer)->getJson(route('bonuses.show', $old))->assertForbidden(); +}); diff --git a/tests/Feature/TimeCalculationTest.php b/tests/Feature/TimeCalculationTest.php new file mode 100644 index 00000000..f205f301 --- /dev/null +++ b/tests/Feature/TimeCalculationTest.php @@ -0,0 +1,218 @@ + 'America/Los_Angeles', 'tracker.day_boundary_hour' => 4]); + $this->event = activateEvent(); + $this->department = Department::factory()->create(['event_id' => $this->event->id]); + $this->user = User::factory()->create(); +}); + +/** + * Parses a time in the tracker's timezone and converts it to UTC, which models store times in + */ +function trackerTime(string $time): Carbon { + return Carbon::parse($time, 'America/Los_Angeles')->utc(); +} + +/** + * Creates a time bonus for departments + */ +function bonusFor(array $departments, string $start, string $stop, float $modifier): TimeBonus { + $bonus = TimeBonus::factory()->create([ + 'event_id' => $departments[0]->event_id, + 'start' => trackerTime($start), + 'stop' => trackerTime($stop), + 'modifier' => $modifier, + ]); + $bonus->departments()->attach(collect($departments)->pluck('id')); + return $bonus; +} + +it('measures ongoing time entries up to now', function () { + $this->freezeSecond(); + $entry = TimeEntry::factory()->create(['start' => now()->subMinutes(90), 'stop' => null]); + + expect($entry->getDuration())->toEqual(5400) + ->and($entry->getClockDuration())->toBe('1:30:00') + ->and($entry->getHumanDuration())->toBe('1h 30m'); +}); + +it('adds bonus time for the overlap with bonuses on the entry\'s department', function () { + timeEntryFor($this->user, $this->department, trackerTime('2026-07-01 10:00'), 240); + bonusFor([$this->department], '2026-07-01 12:00', '2026-07-01 16:00', 2); + bonusFor([Department::factory()->create(['event_id' => $this->event->id])], '2026-07-01 10:00', '2026-07-01 14:00', 3); + + expect($this->user->getTimeStats()['bonus'])->toEqual(7200) + ->and($this->user->getEarnedTime())->toBe(14400 + 7200); +}); + +it('stacks overlapping bonuses', function () { + timeEntryFor($this->user, $this->department, trackerTime('2026-07-01 10:00'), 60); + bonusFor([$this->department], '2026-07-01 10:00', '2026-07-01 11:00', 1.5); + bonusFor([$this->department], '2026-07-01 10:30', '2026-07-01 12:00', 2); + + // 1h at +50% and 30m at +100% + expect($this->user->getTimeStats()['bonus'])->toEqual(1800 + 1800); +}); + +it('totals earned time with bonuses for one event only', function () { + timeEntryFor($this->user, $this->department, trackerTime('2026-07-01 10:00'), 120); + bonusFor([$this->department], '2026-07-01 10:00', '2026-07-01 11:00', 2); + $other = Department::factory()->create(); + timeEntryFor($this->user, $other, trackerTime('2026-07-01 10:00'), 600); + + expect($this->user->getEarnedTime($this->event))->toBe(7200 + 3600) + ->and($this->user->getEarnedTime())->toBe(7200 + 3600) + ->and($this->user->getEarnedTime($other->event))->toBe(36000); +}); + +it('counts time for today from the day boundary hour', function () { + $this->travelTo(trackerTime('2026-07-02 12:00')); + timeEntryFor($this->user, $this->department, trackerTime('2026-07-01 10:00'), 120); + timeEntryFor($this->user, $this->department, trackerTime('2026-07-02 09:00'), 60); + // Crosses the 4am boundary, so only the hour after it counts for today + $crossing = timeEntryFor($this->user, $this->department, trackerTime('2026-07-02 02:00'), 180); + + $stats = $this->user->getTimeStats(); + + expect($crossing->isCrossingDayBoundary())->toBeTrue() + ->and($crossing->getSecondsPastDayBoundary())->toEqual(3600) + ->and($stats['total'])->toEqual(21600) + ->and($stats['bonus'])->toEqual(0) + ->and($stats['day'])->toEqual(7200) + ->and($stats['entries'])->toHaveCount(3); +}); + +it('includes bonus time in the time stats', function () { + $this->travelTo(trackerTime('2026-07-01 20:00')); + timeEntryFor($this->user, $this->department, trackerTime('2026-07-01 10:00'), 60); + $bonus = bonusFor([$this->department], '2026-07-01 10:00', '2026-07-01 11:00', 2); + + $stats = $this->user->getTimeStats(); + + expect($stats['total'])->toEqual(7200) + ->and($stats['bonus'])->toEqual(3600) + ->and($stats['entries'][0]->bonus_time)->toEqual(3600) + ->and($stats['bonuses']->pluck('id')->all())->toBe([$bonus->id]); +}); + +it('returns empty stats without an active event', function () { + Setting::set('active-event', null); + + expect($this->user->getTimeStats())->toMatchArray(['total' => 0, 'bonus' => 0, 'day' => 0]) + ->and($this->user->getEarnedTime())->toBe(0) + ->and($this->user->getRewardInfo()['all'])->toBeEmpty(); +}); + +it('works out which rewards are eligible and claimed', function () { + timeEntryFor($this->user, $this->department, trackerTime('2026-07-01 10:00'), 8 * 60); + $four = Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 4]); + $eight = Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 8]); + $twelve = Reward::factory()->create(['event_id' => $this->event->id, 'hours' => 12]); + $claim = new RewardClaim; + $claim->user_id = $this->user->id; + $claim->reward_id = $four->id; + $claim->save(); + + $info = $this->user->getRewardInfo(); + + // Rewards need more than their hours to be eligible + expect($info['all'])->toHaveCount(3) + ->and($info['eligible']->pluck('id')->all())->toBe([$four->id]) + ->and($info['claimed']->pluck('id')->all())->toBe([$four->id]); + + expect($this->user->getRewardInfo(earnedHours: 12.5)['eligible']->pluck('id')->sort()->values()->all()) + ->toBe(collect([$four->id, $eight->id, $twelve->id])->sort()->values()->all()); +}); + +it('checks roles inclusively unless strict', function () { + $manager = User::factory()->create(['role' => Role::Manager]); + + expect($manager->isStaff())->toBeTrue() + ->and($manager->isLead())->toBeTrue() + ->and($manager->isManager())->toBeTrue() + ->and($manager->isAdmin())->toBeFalse() + ->and($manager->isLead(true))->toBeFalse() + ->and($manager->isManager(true))->toBeTrue() + ->and($manager->isBanned())->toBeFalse() + ->and(User::factory()->create(['role' => Role::Banned])->isBanned())->toBeTrue(); +}); + +it('treats managers and gatekeepers of active event logs as gatekeepers', function () { + $log = new AttendeeLog(['name' => 'Lounge']); + $log->event_id = $this->event->id; + $log->save(); + $gatekeeper = User::factory()->create(); + $log->users()->attach($gatekeeper, ['type' => 'gatekeeper']); + + expect($gatekeeper->isGatekeeper())->toBeTrue() + ->and($gatekeeper->hasAnyBackendAccess())->toBeTrue() + ->and(User::factory()->create(['role' => Role::Manager])->isGatekeeper())->toBeTrue() + ->and($this->user->isGatekeeper())->toBeFalse() + ->and($this->user->hasAnyBackendAccess())->toBeFalse() + ->and(User::factory()->create(['role' => Role::Lead])->hasAnyBackendAccess())->toBeTrue(); +}); + +it('names users by badge name, then username, and marks deleted users', function () { + $user = User::factory()->create(['badge_name' => 'Foxy', 'username' => 'foxy', 'badge_id' => 42]); + $noBadgeName = User::factory()->create(['badge_name' => null, 'username' => 'wolfy']); + + expect($user->display_name)->toBe('Foxy') + ->and($user->audit_name)->toBe('Foxy (#42)') + ->and($noBadgeName->display_name)->toBe('wolfy'); + + $user->delete(); + expect($user->display_name)->toBe('Foxy (del)'); +}); + +it('gives new users a Telegram setup key', function () { + $user = User::factory()->create(); + $key = $user->tg_setup_key; + + expect(strlen($key))->toBe(32) + ->and($user->generateTelegramSetupKey()->tg_setup_key)->not->toBe($key) + ->and($user->toArray())->not->toHaveKeys(['tg_setup_key', 'tg_chat_id']); +}); + +it('only stores real names for volunteers and above when created from a registration', function () { + $registration = (object) [ + 'badgeName' => 'Foxy', + 'user' => (object) ['id' => 1234, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face'], + ]; + + $attendee = User::createFromConCatRegistration($registration, Role::Attendee); + expect($attendee->full_name)->toBe('Unidentified Attendee'); + + $attendee->forceDelete(); + $volunteer = User::createFromConCatRegistration($registration, Role::Volunteer); + expect($volunteer->full_name)->toBe('Fox Face') + ->and($volunteer->badge_name)->toBe('Foxy'); +}); + +it('scopes entities to the active event by default', function () { + $oldEvent = Event::factory()->create(); + Department::factory()->create(['event_id' => $oldEvent->id]); + Reward::factory()->create(['event_id' => $this->event->id]); + Reward::factory()->create(['event_id' => $oldEvent->id]); + + expect(Department::forEvent()->pluck('id')->all())->toBe([$this->department->id]) + ->and(Department::forEvent($oldEvent)->count())->toBe(1) + ->and(Reward::forEvent()->count())->toBe(1) + ->and(Reward::forEvent($oldEvent->id)->count())->toBe(1) + ->and($this->department->isForActiveEvent())->toBeTrue(); +}); diff --git a/tests/Feature/UserManagementTest.php b/tests/Feature/UserManagementTest.php new file mode 100644 index 00000000..72e39f2b --- /dev/null +++ b/tests/Feature/UserManagementTest.php @@ -0,0 +1,214 @@ +event = activateEvent(); + $this->manager = userWithRole(Role::Manager, ['badge_name' => 'Manager']); + $this->admin = userWithRole(Role::Admin, ['badge_name' => 'Admin']); +}); + +it('only lets managers list users', function () { + $this->actingAs(User::factory()->create())->getJson(route('users.index'))->assertForbidden(); + $this->actingAs($this->manager)->getJson(route('users.index'))->assertOk()->assertJsonPath('total', 3); +}); + +it('filters users by name, badge ID, and role', function () { + $foxy = User::factory()->create(['badge_name' => 'Foxy McFoxface', 'badge_id' => 1111]); + User::factory()->create(['badge_name' => 'Wolfy', 'badge_id' => 2222]); + + $this->actingAs($this->manager) + ->getJson(route('users.index', ['name' => 'mcfox'])) + ->assertJsonPath('total', 1) + ->assertJsonPath('users.0.id', $foxy->id); + + $this->actingAs($this->manager) + ->getJson(route('users.index', ['badge_id' => 2222])) + ->assertJsonPath('total', 1) + ->assertJsonPath('users.0.badge_name', 'Wolfy') + ->assertJsonPath('filters.badge_id', 2222); + + $this->actingAs($this->manager) + ->getJson(route('users.index', ['role' => Role::Admin->value])) + ->assertJsonPath('total', 1) + ->assertJsonPath('users.0.id', $this->admin->id); +}); + +it('sorts users by display name by default and by other fields when asked', function () { + User::factory()->create(['badge_name' => 'Zebra', 'badge_id' => 1]); + User::factory()->create(['badge_name' => 'Aardvark', 'badge_id' => 99999999]); + + $this->actingAs($this->manager) + ->getJson(route('users.index')) + ->assertJsonPath('users.0.badge_name', 'Aardvark') + ->assertJsonPath('sortBy', 'display_name') + ->assertJsonPath('sortDir', 'asc'); + + $this->actingAs($this->manager) + ->getJson(route('users.index', ['sortBy' => 'badge_id', 'sortDir' => 'desc'])) + ->assertJsonPath('users.0.badge_name', 'Aardvark') + ->assertJsonPath('users.3.badge_name', 'Zebra'); +}); + +it('paginates users', function () { + User::factory()->count(3)->create(); + + $this->actingAs($this->manager) + ->getJson(route('users.index', ['count' => 2, 'page' => 2])) + ->assertJsonPath('total', 5) + ->assertJsonPath('perPage', 2) + ->assertJsonPath('first', 3) + ->assertJsonCount(2, 'users'); +}); + +it('validates the user list parameters', function (array $params, string $field) { + $this->actingAs($this->manager) + ->getJson(route('users.index', $params)) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + [['sortBy' => 'first_name'], 'sortBy'], + [['sortDir' => 'sideways'], 'sortDir'], + [['count' => 51], 'count'], + [['role' => 5], 'role'], +]); + +it('lets managers create users from a badge ID with their ConCat details', function () { + ConCat::shouldReceive('authorizeIfNeeded'); + ConCat::shouldReceive('getRegistration')->once()->with(1234)->andReturn((object) [ + 'badgeName' => 'Foxy', + 'user' => (object) ['id' => 1234, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face'], + ]); + + $this->actingAs($this->manager) + ->postJson(route('users.store'), ['badge_id' => 1234]) + ->assertOk() + ->assertJsonPath('user.badge_name', 'Foxy'); + + $user = User::whereBadgeId(1234)->sole(); + expect($user->role)->toBe(Role::Volunteer) + ->and($user->username)->toBe('foxy') + ->and($user->full_name)->toBe('Fox Face'); +}); + +it('falls back to the ConCat user when the badge has no registration', function () { + ConCat::shouldReceive('authorizeIfNeeded'); + ConCat::shouldReceive('getRegistration')->andThrow(new RuntimeException('Not found')); + ConCat::shouldReceive('getUser')->once()->with(1234)->andReturn( + (object) ['id' => 1234, 'username' => 'foxy', 'firstName' => 'Fox', 'lastName' => 'Face'], + ); + + $this->actingAs($this->manager)->postJson(route('users.store'), ['badge_id' => 1234])->assertOk(); + + $user = User::whereBadgeId(1234)->sole(); + expect($user->username)->toBe('foxy') + ->and($user->badge_name)->toBeNull(); +}); + +it('creates a placeholder user when ConCat is unavailable', function () { + ConCat::shouldReceive('authorizeIfNeeded')->andThrow(new RuntimeException('ConCat is down')); + + $this->actingAs($this->manager)->postJson(route('users.store'), ['badge_id' => 1234])->assertOk(); + + $user = User::whereBadgeId(1234)->sole(); + expect($user->username)->toBe('unknown') + ->and($user->first_name)->toBe('Unidentified') + ->and($user->last_name)->toBe('Volunteer'); +}); + +it('validates new badge IDs', function (mixed $badgeId) { + User::factory()->create(['badge_id' => 1234]); + + $this->actingAs($this->manager) + ->postJson(route('users.store'), ['badge_id' => $badgeId]) + ->assertUnprocessable() + ->assertJsonValidationErrors('badge_id'); +})->with([1234, 123456789, 'abc', null]); + +it('does not let volunteers create users', function () { + $this->actingAs(User::factory()->create()) + ->postJson(route('users.store'), ['badge_id' => 1234]) + ->assertForbidden(); +}); + +it('lets admins update users', function () { + $user = User::factory()->create(); + + $this->actingAs($this->admin) + ->patchJson(route('users.update', $user), ['badge_name' => 'New Name', 'role' => Role::Lead->value]) + ->assertOk() + ->assertJsonPath('user.badge_name', 'New Name'); + + expect($user->fresh()->role)->toBe(Role::Lead); +}); + +it('does not let managers update users', function () { + $user = User::factory()->create(); + + $this->actingAs($this->manager) + ->patchJson(route('users.update', $user), ['badge_name' => 'New Name']) + ->assertForbidden(); +}); + +it('does not let users change their own role', function () { + $this->actingAs($this->admin) + ->patchJson(route('users.update', $this->admin), ['role' => Role::Volunteer->value]) + ->assertForbidden() + ->assertJsonPath('error', 'Cannot change your own role.'); + + expect($this->admin->fresh()->role)->toBe(Role::Admin); +}); + +it('rejects unknown roles', function () { + $this->actingAs($this->admin) + ->patchJson(route('users.update', User::factory()->create()), ['role' => 9]) + ->assertUnprocessable() + ->assertJsonValidationErrors('role'); +}); + +it('lets admins delete other users, but not themselves', function () { + $user = User::factory()->create(); + + $this->actingAs($this->manager)->deleteJson(route('users.destroy', $user))->assertForbidden(); + $this->actingAs($this->admin) + ->deleteJson(route('users.destroy', $this->admin)) + ->assertForbidden() + ->assertJsonPath('error', 'Cannot delete yourself.'); + $this->actingAs($this->admin)->deleteJson(route('users.destroy', $user))->assertStatus(205); + + expect($user->fresh()->trashed())->toBeTrue(); +}); + +it('searches users by name or badge ID, including their ongoing time entries', function () { + $department = Department::factory()->create(['event_id' => $this->event->id]); + $foxy = User::factory()->create(['badge_name' => 'Foxy', 'badge_id' => 4321]); + TimeEntry::factory()->ongoing()->create([ + 'user_id' => $foxy->id, + 'event_id' => $this->event->id, + 'department_id' => $department->id, + ]); + + $this->actingAs($this->manager) + ->getJson(route('users.search', ['q' => 'FOX'])) + ->assertOk() + ->assertJsonCount(1, 'users') + ->assertJsonPath('users.0.id', $foxy->id) + ->assertJsonPath('users.0.time_entries.0.department.id', $department->id); + + $this->actingAs($this->manager) + ->getJson(route('users.search', ['q' => '4321'])) + ->assertJsonPath('users.0.id', $foxy->id); +}); + +it('only lets managers search users', function () { + $this->actingAs(User::factory()->create()) + ->getJson(route('users.search', ['q' => 'fox'])) + ->assertForbidden(); +}); diff --git a/tests/Feature/VolunteerTest.php b/tests/Feature/VolunteerTest.php new file mode 100644 index 00000000..f389e7c0 --- /dev/null +++ b/tests/Feature/VolunteerTest.php @@ -0,0 +1,226 @@ +withoutVite(); + $this->event = activateEvent(); + $this->department = Department::factory()->visible()->create(['event_id' => $this->event->id]); + $this->volunteer = User::factory()->telegramUnlinked()->create(); + fakeTelegram()->shouldReceive('getMe')->andReturn((object) ['username' => 'TrackerBot']); +}); + +it('shows the volunteer home with only visible departments for volunteers', function () { + $hidden = Department::factory()->hidden()->create(['event_id' => $this->event->id]); + + $this->actingAs($this->volunteer) + ->get(route('volunteer.index')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('VolunteerHome') + ->has('departments', 1) + ->where('departments.0.id', $this->department->id) + ->where('telegramSetupUrl', "https://t.me/TrackerBot?start={$this->volunteer->tg_setup_key}") + ->where('volunteer.user.id', $this->volunteer->id)); + + $this->actingAs(userWithRole(Role::Manager)) + ->get(route('volunteer.index')) + ->assertInertia(fn (AssertableInertia $page) => $page->has('departments', 2)); +}); + +it('sends volunteers with unread notifications to their notifications first', function () { + $this->volunteer->notify(new RewardClaimed(Reward::factory()->create(['event_id' => $this->event->id]))); + + $this->actingAs($this->volunteer) + ->get(route('volunteer.index')) + ->assertRedirect(route('notifications.index')); +}); + +it('sends volunteers to the lockdown notice while the site is locked down, but not managers', function () { + Setting::set('lockdown', true); + + $this->actingAs($this->volunteer)->get(route('volunteer.index'))->assertRedirect(route('volunteer.lockdown')); + $this->actingAs($this->volunteer) + ->get(route('volunteer.lockdown')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page->component('Maintenance')); + + $this->actingAs(userWithRole(Role::Manager))->get(route('volunteer.index'))->assertOk(); +}); + +it('sends users away from the lockdown notice when the site is not locked down', function () { + $this->actingAs($this->volunteer)->get(route('volunteer.lockdown'))->assertRedirect(route('volunteer.index')); +}); + +it('lets volunteers check in at a kiosk', function () { + $kiosk = Kiosk::create(); + + $this->actingAs($this->volunteer) + ->withCredentials() + ->withCookie('kiosk', $kiosk->session_key) + ->postJson(route('volunteer.time.checkin', $this->event), ['department_id' => $this->department->id]) + ->assertOk() + ->assertJsonPath('time_entry.department_id', $this->department->id); + + $entry = $this->volunteer->timeEntries()->sole(); + expect($entry->isOngoing())->toBeTrue() + ->and($entry->creator_user_id)->toBe($this->volunteer->id) + ->and($entry->event_id)->toBe($this->event->id); +}); + +it('does not let volunteers check in outside of a kiosk, but lets staff', function () { + $this->actingAs($this->volunteer) + ->postJson(route('volunteer.time.checkin', $this->event), ['department_id' => $this->department->id]) + ->assertForbidden(); + + $staff = userWithRole(Role::Staff); + $this->actingAs($staff) + ->postJson(route('volunteer.time.checkin', $this->event), ['department_id' => $this->department->id]) + ->assertOk(); +}); + +it('lets volunteers check in anywhere in dev mode', function () { + Setting::set('dev-mode', true); + + $this->actingAs($this->volunteer) + ->postJson(route('volunteer.time.checkin', $this->event), ['department_id' => $this->department->id]) + ->assertOk(); +}); + +it('does not allow checking in for an event that is not active', function () { + $other = Event::factory()->create(); + $department = Department::factory()->create(['event_id' => $other->id]); + + $this->actingAs(userWithRole(Role::Staff)) + ->postJson(route('volunteer.time.checkin', $other), ['department_id' => $department->id]) + ->assertForbidden(); +}); + +it('requires a department from the same event to check in', function () { + $otherDepartment = Department::factory()->create(); + + $this->actingAs(userWithRole(Role::Staff)) + ->postJson(route('volunteer.time.checkin', $this->event), ['department_id' => $otherDepartment->id]) + ->assertUnprocessable() + ->assertJsonValidationErrors('department_id'); +}); + +it('does not allow checking in twice', function () { + $staff = userWithRole(Role::Staff); + TimeEntry::factory()->ongoing()->create([ + 'user_id' => $staff->id, + 'event_id' => $this->event->id, + 'department_id' => $this->department->id, + ]); + + $this->actingAs($staff) + ->postJson(route('volunteer.time.checkin', $this->event), ['department_id' => $this->department->id]) + ->assertConflict() + ->assertJsonPath('error', 'Cannot check in with an already-ongoing time entry.'); +}); + +it('checks out an ongoing time entry and returns updated stats', function () { + $staff = userWithRole(Role::Staff); + $entry = TimeEntry::factory()->create([ + 'user_id' => $staff->id, + 'event_id' => $this->event->id, + 'department_id' => $this->department->id, + 'start' => now()->subHours(2), + 'stop' => null, + ]); + + $this->actingAs($staff) + ->postJson(route('volunteer.time.checkout', $entry)) + ->assertOk() + ->assertJsonPath('stats.total', 7200); + + expect($entry->fresh()->isOngoing())->toBeFalse(); + + $this->actingAs($staff) + ->postJson(route('volunteer.time.checkout', $entry)) + ->assertConflict() + ->assertJsonPath('error', 'The time entry has already been checked out.'); +}); + +it('does not let volunteers check out other volunteers', function () { + $entry = TimeEntry::factory()->ongoing()->create([ + 'event_id' => $this->event->id, + 'department_id' => $this->department->id, + ]); + + $this->actingAs(userWithRole(Role::Staff)) + ->postJson(route('volunteer.time.checkout', $entry)) + ->assertForbidden(); + + $this->actingAs(userWithRole(Role::Manager)) + ->postJson(route('volunteer.time.checkout', $entry)) + ->assertOk(); +}); + +it('lets managers create finished time entries for volunteers, keeping notes', function () { + $manager = userWithRole(Role::Manager); + $start = now()->subHours(3)->startOfMinute(); + + $this->actingAs($manager) + ->putJson(route('volunteer.time.store', [$this->event, $this->volunteer]), [ + 'department_id' => $this->department->id, + 'start' => $start->toISOString(), + 'stop' => $start->avoidMutation()->addHours(2)->toISOString(), + 'notes' => 'Forgot to clock in', + ]) + ->assertOk(); + + $entry = $this->volunteer->timeEntries()->sole(); + expect($entry->getDuration())->toEqual(7200) + ->and($entry->notes)->toBe('Forgot to clock in') + ->and($entry->creator_user_id)->toBe($manager->id); +}); + +it('rejects time entries that stop before they start', function () { + $start = now()->subHours(3); + + $this->actingAs(userWithRole(Role::Manager)) + ->putJson(route('volunteer.time.store', [$this->event, $this->volunteer]), [ + 'department_id' => $this->department->id, + 'start' => $start->toISOString(), + 'stop' => $start->avoidMutation()->subHour()->toISOString(), + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('stop'); +}); + +it('does not let volunteers create time entries for other users', function () { + $this->actingAs(userWithRole(Role::Staff)) + ->putJson(route('volunteer.time.store', [$this->event, $this->volunteer]), [ + 'department_id' => $this->department->id, + ]) + ->assertForbidden(); +}); + +it('only lets managers delete time entries for the active event, and admins any', function () { + $entry = TimeEntry::factory()->finished()->create([ + 'user_id' => $this->volunteer->id, + 'event_id' => $this->event->id, + 'department_id' => $this->department->id, + ]); + $oldEntry = TimeEntry::factory()->finished()->create(); + + $this->actingAs($this->volunteer)->deleteJson(route('volunteer.time.destroy', $entry))->assertForbidden(); + $this->actingAs(userWithRole(Role::Manager))->deleteJson(route('volunteer.time.destroy', $oldEntry))->assertForbidden(); + $this->actingAs(userWithRole(Role::Manager))->deleteJson(route('volunteer.time.destroy', $entry))->assertStatus(205); + $this->actingAs(userWithRole(Role::Admin))->deleteJson(route('volunteer.time.destroy', $oldEntry))->assertStatus(205); + + expect(TimeEntry::count())->toBe(0); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 65284385..fb8e68e3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -45,3 +45,58 @@ function something() { // .. } + +/** + * Makes an event the active one and opens the site up by lifting the lockdown and turning off dev mode. + * The settings migration starts with the site locked down and in dev mode. + */ +function activateEvent(?App\Models\Event $event = null): App\Models\Event { + $event ??= App\Models\Event::factory()->create(); + App\Models\Setting::set('active-event', $event); + App\Models\Setting::set('lockdown', false); + App\Models\Setting::set('dev-mode', false); + return $event; +} + +/** + * Creates a user with a specific role + */ +function userWithRole(App\Models\Role $role, array $attributes = []): App\Models\User { + return App\Models\User::factory()->create(['role' => $role, ...$attributes]); +} + +/** + * Gets the headers for an Inertia request, including the current asset version so it isn't rejected + */ +function inertiaHeaders(array $extra = []): array { + return [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) app(App\Http\Middleware\HandleInertiaRequests::class)->version(request()), + ...$extra, + ]; +} + +/** + * Replaces the Telegram facade with a mock and returns it. + * The facade's BotsManager class is final, so the facade's shouldReceive() can't be used. + */ +function fakeTelegram(): Mockery\MockInterface { + $mock = Mockery::mock(); + Telegram\Bot\Laravel\Facades\Telegram::swap($mock); + return $mock; +} + +/** + * Creates a finished time entry for a user in a department, lasting a specific number of minutes + */ +function timeEntryFor(App\Models\User $user, App\Models\Department $department, Carbon\CarbonInterface $start, int $minutes, array $attributes = []): App\Models\TimeEntry { + return App\Models\TimeEntry::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $department->event_id, + 'department_id' => $department->id, + 'start' => $start, + 'stop' => $start->avoidMutation()->addMinutes($minutes), + 'auto' => false, + ...$attributes, + ]); +} diff --git a/tests/TestCase.php b/tests/TestCase.php index b2021bd6..d290514f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,22 @@ namespace Tests; +use App\Models\Kiosk; +use App\Models\Setting; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { - // + protected function setUp(): void { + parent::setUp(); + static::resetStaticCaches(); + } + + /** + * Clears the settings and kiosk authorization caches that models keep in static properties. + * They would otherwise carry over between tests and between requests made within a single test. + */ + public static function resetStaticCaches(): void { + (fn () => static::$settingsCache = [])->bindTo(null, Setting::class)(); + (fn () => static::$authorizedCache = null)->bindTo(null, Kiosk::class)(); + } } diff --git a/tests/Unit/RoleTest.php b/tests/Unit/RoleTest.php new file mode 100644 index 00000000..4eef2bf3 --- /dev/null +++ b/tests/Unit/RoleTest.php @@ -0,0 +1,50 @@ +toBe($role); +})->with([ + ['admin', Role::Admin], + ['MANAGER', Role::Manager], + ['banned', Role::Banned], +]); + +it('rejects unknown role names', function () { + Role::fromName('overlord'); +})->throws(ValueError::class, 'Unknown role name: Overlord'); + +it('has a description, color, and action label for every role', function (Role $role) { + expect($role->description())->toBeString()->not->toBeEmpty() + ->and($role->colorClass())->toBeString()->not->toBeEmpty() + ->and($role->actionLabel())->toBeString()->not->toBeEmpty(); +})->with(Role::cases()); + +it('orders roles from banned up to admin', function () { + $values = array_map(fn (Role $role) => $role->value, [ + Role::Banned, + Role::Attendee, + Role::Volunteer, + Role::Staff, + Role::Lead, + Role::Manager, + Role::Admin, + ]); + + expect($values)->toBe([-2, -1, 0, 1, 2, 3, 4]); +}); + +it('stores setting values as JSON', function (mixed $value, string $json) { + $cast = new JsonValue; + $setting = new Setting; + + expect($cast->set($setting, 'value', $value, []))->toBe($json) + ->and($cast->get($setting, 'value', $json, []))->toBe($value); +})->with([ + [true, 'true'], + [null, 'null'], + ['01a0edc0-18ad-7070-ba80-4df1b7ca2455', '"01a0edc0-18ad-7070-ba80-4df1b7ca2455"'], + [['a' => 1], '{"a":1}'], +]); diff --git a/tests/Unit/TimeFormattingTest.php b/tests/Unit/TimeFormattingTest.php new file mode 100644 index 00000000..cebcd15e --- /dev/null +++ b/tests/Unit/TimeFormattingTest.php @@ -0,0 +1,36 @@ +toBe($expected); +})->with([ + 'partial overlap' => ['10:00', '14:00', '12:00', '16:00', 7200], + 'contained' => ['10:00', '14:00', '11:00', '12:00', 3600], + 'containing' => ['11:00', '12:00', '10:00', '14:00', 3600], + 'touching' => ['10:00', '12:00', '12:00', '14:00', 0], + 'apart' => ['10:00', '11:00', '13:00', '14:00', 0], +]); + +it('formats durations for humans', function (int $seconds, string $expected) { + expect(TimeEntry::humanDuration($seconds))->toBe($expected); +})->with([ + [24000, '6h 40m'], + [90000, '25h'], + [59, '59s'], +]); + +it('formats durations like a clock', function (int $seconds, string $expected) { + expect(TimeEntry::clockDuration($seconds))->toBe($expected); +})->with([ + [0, '0:00'], + [65, '1:05'], + [18392, '5:06:32'], + [3600, '1:00:00'], +]);