client = self::createClient(server: ['HTTPS' => true]); $this->client->disableReboot(); $this->em = self::getContainer()->get(EntityManagerInterface::class); $this->em->getConnection()->beginTransaction(); } protected function tearDown(): void { $this->em->getConnection()->rollBack(); parent::tearDown(); } /** * Supervisors free bookings themselves, but the history across the whole staff is the * administrators' alone. */ public function testOnlyAnAdminCanReachTheHistory(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('GET', '/admin/history'); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->loginUser($this->user('sam@ep-reisen.de', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR'])); $this->client->request('GET', '/admin/history'); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->loginUser($this->admin()); $this->client->request('GET', '/admin/history'); self::assertResponseIsSuccessful(); } public function testTheDashboardTileIsForAdminsOnly(): void { $this->client->loginUser($this->user('sam@ep-reisen.de', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR'])); self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/history"]')); $this->client->loginUser($this->admin()); self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/history"]')); } public function testAReleaseOnSomebodysBehalfShowsBothPeople(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $booking = new Booking($bea, $this->resource('Tisch 12'), new \DateTimeImmutable('today')); $this->em->persist($booking); $this->em->flush(); $this->client->loginUser($this->admin()); $uri = \sprintf('/bookings/%d/release', $booking->getId()); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); $crawler = $this->client->request('GET', '/admin/history'); self::assertResponseIsSuccessful(); $row = $crawler->filter('tbody tr')->first()->text(); self::assertStringContainsString('Buchung freigegeben', $row); self::assertStringContainsString('Tisch 12', $row); self::assertStringContainsString('Bea Berger', $row); self::assertStringContainsString('Chef Chefin', $row); self::assertStringContainsString('stellvertretend', $row); } public function testSomebodyActingOnTheirOwnBookingIsNotMarked(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/history'); self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text()); } /** * A console command or the worker has no booking of its own, so it acts for nobody. */ public function testTheSystemActingIsNotMarked(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $event = (new BookingEvent(BookingEventType::BOOKING_RELEASED, null, BookingEvent::SYSTEM_ACTOR, null, 'Tisch 1', ResourceType::DESK)) ->setHolder($bea); $this->em->persist($event); $this->em->flush(); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/history'); self::assertStringContainsString('Bea Berger', $crawler->filter('tbody')->text()); self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text()); } /** * The person filter is about who carried the change out: Carl's booking freed by Bea is * Bea's line, not Carl's. */ public function testThePersonFilterMatchesWhoActed(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $carl = $this->user('carl@ep-reisen.de', 'Carl Conrad'); $this->event(BookingEventType::BOOKING_RELEASED, $bea, $carl, 'Tisch 1'); $this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2'); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/history?actor='.$bea->getId()); self::assertResponseIsSuccessful(); $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('Tisch 1', $text); self::assertStringNotContainsString('Tisch 2', $text); } public function testTheTypeFilterAcceptsSeveralKinds(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1'); $this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea, 'Tisch 2'); $this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea, 'Tisch 3'); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/history?types[]=booking_created&types[]=booking_released'); self::assertResponseIsSuccessful(); $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('Tisch 1', $text); self::assertStringNotContainsString('Tisch 2', $text); self::assertStringContainsString('Tisch 3', $text); } public function testTheResourceTypeFilter(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'P1', ResourceType::PARKING); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/history?resourceType=parking'); $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('P1', $text); self::assertStringNotContainsString('Tisch 1', $text); } /** * The window is about when it happened. The default reaches back thirty days, and the "to" * day is included in full, not cut off at its midnight. */ public function testTheDateWindowIsAboutWhenItHappened(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch alt'), '-40 days'); $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch gestern'), 'yesterday 23:30'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch heute'); $this->em->clear(); $this->client->loginUser($this->admin()); $text = $this->client->request('GET', '/admin/history')->filter('tbody')->text(); self::assertStringNotContainsString('Tisch alt', $text); self::assertStringContainsString('Tisch gestern', $text); self::assertStringContainsString('Tisch heute', $text); $yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d'); $text = $this->client->request('GET', '/admin/history?from='.$yesterday.'&to='.$yesterday)->filter('tbody')->text(); self::assertStringContainsString('Tisch gestern', $text); self::assertStringNotContainsString('Tisch heute', $text); } public function testTheNewestEventComesFirst(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch früher'), '-2 hours'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch später'); $this->em->clear(); $this->client->loginUser($this->admin()); $rows = $this->client->request('GET', '/admin/history')->filter('tbody tr'); self::assertStringContainsString('Tisch später', $rows->eq(0)->text()); self::assertStringContainsString('Tisch früher', $rows->eq(1)->text()); $rows = $this->client->request('GET', '/admin/history?sort=e.occurredAt&direction=asc')->filter('tbody tr'); self::assertStringContainsString('Tisch früher', $rows->eq(0)->text()); } public function testSortingByWhoActedKeepsTheFilter(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $carl = $this->user('carl@ep-reisen.de', 'Carl Conrad'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1'); $this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2'); $this->event(BookingEventType::BOOKING_RELEASED, $carl, $carl, 'Tisch 3'); $this->client->loginUser($this->admin()); $rows = $this->client ->request('GET', '/admin/history?types[]=booking_created&sort=e.actorName&direction=desc') ->filter('tbody tr'); self::assertCount(2, $rows); self::assertStringContainsString('Carl Conrad', $rows->eq(0)->text()); self::assertStringContainsString('Bea Berger', $rows->eq(1)->text()); } /** * Every kind has its own branch in the "Buchung" column; each is rendered once here so a typo * in one of them cannot hide until somebody filters for it. */ public function testEveryKindOfEventRenders(): void { $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea); $this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea) ->setTimeRange(TimeRange::fromMinutes(600, 720), TimeRange::fromMinutes(540, 780)); $this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea); $this->event(BookingEventType::SERIES_CREATED, $bea, $bea) ->setDates(new \DateTimeImmutable('today'), new \DateTimeImmutable('+4 weeks')) ->setDetails(['rule' => 'weekly', 'created' => 4, 'skipped' => ['2026-10-20']]); $this->event(BookingEventType::SERIES_ENDED, $bea, $bea) ->setDetails(['rule' => 'weekly', 'removed' => 3]); $deleted = new BookingEvent(BookingEventType::RESOURCE_DELETED, $bea, 'Bea Berger', 7, 'Tisch 9', ResourceType::DESK); $this->em->persist($deleted->setDetails(['removed' => 5])); $this->em->flush(); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/history'); self::assertResponseIsSuccessful(); $text = $crawler->filter('tbody')->text(); self::assertStringContainsString('09:00–13:00 → 10:00–12:00', $text); self::assertStringContainsString('Wöchentlich', $text); self::assertStringContainsString('4 Termine, 1 übersprungen', $text); self::assertStringContainsString('3 Termine entfernt', $text); self::assertStringContainsString('5 Buchungen gelöscht', $text); self::assertStringContainsString('6 Ereignisse', $crawler->filter('body')->text()); } private function event( BookingEventType $type, User $actor, User $holder, string $label = 'Tisch 1', ResourceType $resourceType = ResourceType::DESK, ): BookingEvent { $event = (new BookingEvent($type, $actor, $actor->getName(), null, $label, $resourceType)) ->setHolder($holder) ->setDates(new \DateTimeImmutable('today')) ->setTimeRange(TimeRange::wholeDay()); $this->em->persist($event); $this->em->flush(); return $event; } /** * The constructor stamps the current time and nothing should be able to change it afterwards, * so a test that needs an older event rewrites the row. Call clear() once the setup is done, * or the page renders the stale time still held in the identity map. */ private function occurredAt(BookingEvent $event, string $when): void { $this->em->getConnection()->executeStatement( 'UPDATE booking_event SET occurred_at = ? WHERE id = ?', [(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()], ); } private function confirmationToken(string $uri): string { $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); $vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals'); return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; } private function admin(): User { return $this->user('chef@ep-reisen.de', 'Chef Chefin', ['ROLE_ADMIN']); } /** * @param list $roles */ private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User { $user = new User($email, $name); $user->setRoles($roles); $this->em->persist($user); $this->em->flush(); return $user; } private function resource(string $label): Resource { $resource = new Resource(ResourceType::DESK, $label); $this->em->persist($resource); $this->em->flush(); return $resource; } }