client = self::createClient(); $this->client->disableReboot(); $this->em = self::getContainer()->get(EntityManagerInterface::class); $this->em->getConnection()->beginTransaction(); } protected function tearDown(): void { $this->em->getConnection()->rollBack(); parent::tearDown(); } public function testAnEmployeeCannotReachIt(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('GET', '/admin/resources'); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->request('GET', '/admin/resources/new/desk', server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); } public function testTheListShowsRoomsAndRetiredResources(): void { $room = $this->group('Raum Nord'); $this->resource('Schreibtisch 01', $room); $this->resource('Schreibtisch 02', $room)->setActive(false); $this->em->flush(); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/resources'); self::assertResponseIsSuccessful(); $desks = $crawler->filter('[data-type="desk"]')->text(); self::assertStringContainsString('Raum Nord', $desks); self::assertStringContainsString('Schreibtisch 01', $desks); self::assertStringContainsString('Schreibtisch 02 nicht buchbar', preg_replace('/\s+/', ' ', $desks) ?? ''); } public function testCreatingAResourceInARoom(): void { $room = $this->group('Raum Nord'); $this->client->loginUser($this->admin()); $response = $this->submit('/admin/resources/new/desk', 'resource_form', ['label' => 'Schreibtisch 13', 'group' => (string) $room->getId()]); self::assertSame('/admin/resources', $response->headers->get('HX-Redirect')); $created = $this->em->getRepository(Resource::class)->findOneBy(['label' => 'Schreibtisch 13']); self::assertNotNull($created); self::assertSame(ResourceType::DESK, $created->getType()); self::assertSame($room->getId(), $created->getGroup()?->getId()); self::assertTrue($created->isActive()); } public function testADuplicateLabelIsRejectedInTheModal(): void { $this->resource('Schreibtisch 01'); $this->client->loginUser($this->admin()); $response = $this->submit('/admin/resources/new/desk', 'resource_form', ['label' => 'Schreibtisch 01']); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertStringContainsString('bereits vergeben', (string) $response->getContent()); } /** * The same label is fine for another type -- uniqueness is per type, like the database's. */ public function testTheSameLabelMayExistForAnotherType(): void { $this->resource('A1'); $this->client->loginUser($this->admin()); $response = $this->submit('/admin/resources/new/parking', 'resource_form', ['label' => 'A1']); self::assertSame('/admin/resources', $response->headers->get('HX-Redirect')); } /** * The group choices are limited to the resource's type, so a parking area cannot be forced * onto a desk by editing the request. */ public function testARoomOfAnotherTypeIsRejected(): void { $area = $this->group('Tiefgarage', ResourceType::PARKING); $this->client->loginUser($this->admin()); $response = $this->submit('/admin/resources/new/desk', 'resource_form', ['label' => 'Schreibtisch 13', 'group' => (string) $area->getId()]); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertNull($this->em->getRepository(Resource::class)->findOneBy(['label' => 'Schreibtisch 13'])); } public function testAnUnknownTypeIsNotFound(): void { $this->client->loginUser($this->admin()); $this->client->request('GET', '/admin/resources/new/boat', server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND); } /** * Retiring keeps the bookings and says how many are left, and the resource leaves the plan. */ public function testRetiringAResourceWithUpcomingBookings(): void { $desk = $this->resource('Schreibtisch 01'); $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('+1 day'))); $this->em->flush(); $this->client->loginUser($this->admin()); // An unticked checkbox is simply absent from the submit. $response = $this->submit(\sprintf('/admin/resources/%d/edit', $desk->getId()), 'resource_form', ['label' => 'Schreibtisch 01']); self::assertSame('/admin/resources', $response->headers->get('HX-Redirect')); $desk = $this->reload($desk); self::assertFalse($desk->isActive()); self::assertCount(1, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk])); $this->client->request('GET', '/admin/resources'); self::assertStringContainsString('1 bestehende Buchungen ab heute bleiben erhalten', (string) $this->client->getResponse()->getContent()); $board = $this->client->request('GET', '/book/desk'); self::assertCount(0, $board->filter('[data-spot]')); } public function testReactivatingAResource(): void { $desk = $this->resource('Schreibtisch 01'); $desk->setActive(false); $this->em->flush(); $this->client->loginUser($this->admin()); $this->submit(\sprintf('/admin/resources/%d/edit', $desk->getId()), 'resource_form', ['label' => 'Schreibtisch 01', 'active' => '1']); self::assertTrue($this->reload($desk)->isActive()); } public function testANewRoomIsPrefilledToComeLast(): void { $this->group('Raum Nord', position: 0); $this->group('Raum Süd', position: 4); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/resource-groups/new/desk', server: ['HTTP_HX-Request' => 'true']); self::assertSame('5', $crawler->filter('#resource_group_form_position')->attr('value')); } public function testCreatingAndRenamingARoom(): void { $this->client->loginUser($this->admin()); $response = $this->submit('/admin/resource-groups/new/desk', 'resource_group_form', ['label' => 'Raum West', 'position' => '2']); self::assertSame('/admin/resources', $response->headers->get('HX-Redirect')); $room = $this->em->getRepository(ResourceGroup::class)->findOneBy(['label' => 'Raum West']); self::assertNotNull($room); self::assertSame(ResourceType::DESK, $room->getType()); $this->submit(\sprintf('/admin/resource-groups/%d/edit', $room->getId()), 'resource_group_form', ['label' => 'Raum Ost', 'position' => '1']); $room = $this->reload($room); self::assertSame('Raum Ost', $room->getLabel()); self::assertSame(1, $room->getPosition()); } public function testDeletingAnEmptyRoom(): void { $room = $this->group('Raum Nord'); $id = $room->getId(); $this->client->loginUser($this->admin()); $uri = \sprintf('/admin/resource-groups/%d/delete', $id); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect')); $this->em->clear(); self::assertNull($this->em->find(ResourceGroup::class, $id)); } /** * A retired resource still sits in the room, so it counts. */ public function testARoomWithResourcesCannotBeDeleted(): void { $room = $this->group('Raum Nord'); $this->resource('Schreibtisch 01', $room)->setActive(false); $this->em->flush(); $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/resources'); self::assertCount(0, $crawler->filter(\sprintf('[hx-get="/admin/resource-groups/%d/delete"]', $room->getId()))); $this->client->request('POST', \sprintf('/admin/resource-groups/%d/delete', $room->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); } public function testAnEmployeeCannotDeleteAResource(): void { $desk = $this->resource('Schreibtisch 01'); $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('POST', \sprintf('/admin/resources/%d/delete', $desk->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); self::assertNotNull($this->reload($desk)); } /** * Unlike retiring, deleting takes the bookings and series with it -- the foreign keys would * refuse otherwise -- and the confirmation says how many go. */ public function testDeletingAResourceTakesItsBookingsWithIt(): void { $desk = $this->resource('Schreibtisch 01'); $other = $this->resource('Schreibtisch 02'); $bea = $this->user('bea@ep-reisen.de'); $series = new BookingSeries($bea, $desk, RecurrenceRule::DAILY, new \DateTimeImmutable('+1 day'), new \DateTimeImmutable('+2 days'), TimeRange::wholeDay()); $this->em->persist($series); foreach ($series->occurrences() as $day) { $this->em->persist(new Booking($bea, $desk, $day, series: $series)); } $this->em->persist(new Booking($bea, $desk, new \DateTimeImmutable('-3 days'))); $this->em->persist(new Booking($bea, $other, new \DateTimeImmutable('+1 day'))); $this->em->flush(); $deskId = $desk->getId(); $this->client->loginUser($this->admin()); $uri = \sprintf('/admin/resources/%d/delete', $deskId); $crawler = $this->client->request('GET', '/admin/resources'); self::assertCount(1, $crawler->filter(\sprintf('[hx-get="%s"]', $uri))); $modal = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); self::assertStringContainsString('2 Buchungen ab heute und 1 vergangene', $modal->filter('[data-booking-count]')->text()); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect')); $this->em->clear(); self::assertNull($this->em->find(Resource::class, $deskId)); self::assertCount(0, $this->em->getRepository(BookingSeries::class)->findAll()); $remaining = $this->em->getRepository(Booking::class)->findAll(); self::assertCount(1, $remaining); self::assertSame($other->getId(), $remaining[0]->getResource()->getId()); } public function testDeletingAResourceNeedsAValidToken(): void { $desk = $this->resource('Schreibtisch 01'); $this->client->loginUser($this->admin()); $this->client->request('POST', \sprintf('/admin/resources/%d/delete', $desk->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->em->clear(); self::assertNotNull($this->em->find(Resource::class, $desk->getId())); } public function testTheDashboardLinksHereForAdminsOnly(): void { $this->client->loginUser($this->user('anna@ep-reisen.de')); self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/resources"]')); $this->client->loginUser($this->admin()); self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/resources"]')); } /** * Opens the modal, then posts the given fields with its CSRF token -- the round trip a * browser makes. * * @param array $fields */ private function submit(string $uri, string $formName, array $fields): Response { $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); self::assertResponseIsSuccessful(); $fields['_token'] = $crawler->filter(\sprintf('#%s__token', $formName))->attr('value'); $this->client->request('POST', $uri, [$formName => $fields], server: ['HTTP_HX-Request' => 'true']); return $this->client->getResponse(); } 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']; } /** * Each request resets the entity manager, so what a test holds is detached afterwards. * * @template T of Resource|ResourceGroup * * @param T $entity * * @return T */ private function reload(Resource|ResourceGroup $entity): Resource|ResourceGroup { $fresh = $this->em->find($entity::class, $entity->getId()); self::assertNotNull($fresh); return $fresh; } private function group(string $label, ResourceType $type = ResourceType::DESK, int $position = 0): ResourceGroup { $group = new ResourceGroup($type, $label, $position); $this->em->persist($group); $this->em->flush(); return $group; } private function resource(string $label, ?ResourceGroup $group = null): Resource { $resource = new Resource(ResourceType::DESK, $label); $resource->setGroup($group); $this->em->persist($resource); $this->em->flush(); return $resource; } private function admin(): User { return $this->user('chef@ep-reisen.de', ['ROLE_ADMIN']); } /** * @param list $roles */ private function user(string $email, array $roles = ['ROLE_EMPLOYEE']): User { $existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]); if (null !== $existing) { return $existing; } $user = new User($email, 'Test'); $user->setRoles($roles); $this->em->persist($user); $this->em->flush(); return $user; } }