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(); } public function testAnEmployeeCannotReachIt(): void { $code = $this->code('Aufzug', '4711'); $this->client->loginUser($this->user('anna@ep-reisen.de')); $this->client->request('GET', '/admin/access-codes'); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->request('GET', '/admin/access-codes/new', server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->request('GET', \sprintf('/admin/access-codes/%d/edit', $code->getId()), server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->client->request('POST', \sprintf('/admin/access-codes/%d/delete', $code->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->em->clear(); self::assertNotNull($this->em->find(AccessCode::class, $code->getId())); } /** * Every row, newest per door first, each marked with what it is today: the one in force * carries no badge, its successor is „noch nicht gültig" and its predecessor „ersetzt". */ public function testTheTableMarksUpcomingAndSupersededCodes(): void { $this->code('Aufzug', 'OLD', validFrom: '-2 months'); $this->code('Aufzug', 'NOW', validFrom: '-1 day'); $this->code('Aufzug', 'NEXT', validFrom: '+1 month'); $this->client->loginUser($this->admin()); $rows = $this->client->request('GET', '/admin/access-codes')->filter('[data-access-code]'); self::assertResponseIsSuccessful(); self::assertCount(3, $rows); self::assertStringContainsString('NEXT', $rows->eq(0)->text()); self::assertStringContainsString('noch nicht gültig', $rows->eq(0)->text()); self::assertStringContainsString('NOW', $rows->eq(1)->text()); self::assertStringNotContainsString('ersetzt', $rows->eq(1)->text()); self::assertStringNotContainsString('noch nicht gültig', $rows->eq(1)->text()); self::assertStringContainsString('OLD', $rows->eq(2)->text()); self::assertStringContainsString('ersetzt', $rows->eq(2)->text()); } /** * A door whose code only starts next week has nothing in force -- and nothing may be marked * as replaced, because nothing has replaced anything yet. */ public function testACodeThatHasNotStartedYetSupersedesNothing(): void { $this->code('Schranke', 'SOON', validFrom: '+1 week'); $this->client->loginUser($this->admin()); $rows = $this->client->request('GET', '/admin/access-codes')->filter('[data-access-code]'); self::assertCount(1, $rows); self::assertStringContainsString('noch nicht gültig', $rows->text()); self::assertStringNotContainsString('ersetzt', $rows->text()); } public function testTheQuickLookOffersTheTableToAnAdmin(): void { $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/access-codes'); self::assertCount(1, $crawler->filter('a[href="/access-codes"]')); } public function testCreatingACode(): void { $this->client->loginUser($this->admin()); $response = $this->submit('/admin/access-codes/new', 'access_code_form', [ 'label' => 'Aufzug', 'description' => 'Kabine im Erdgeschoss', 'code' => '4711', 'validFrom' => '2026-10-01', 'visibility' => 'everyone', ]); self::assertSame('/admin/access-codes', $response->headers->get('HX-Redirect')); $created = $this->em->getRepository(AccessCode::class)->findOneBy(['label' => 'Aufzug']); self::assertNotNull($created); self::assertSame('4711', $created->getCode()); self::assertSame('Kabine im Erdgeschoss', $created->getDescription()); self::assertSame('2026-10-01', $created->getValidFrom()->format('Y-m-d')); self::assertSame(CodeVisibility::EVERYONE, $created->getVisibility()); } /** * The date is prefilled with today, because a code being entered usually already works. */ public function testTheNewFormStartsToday(): void { $this->client->loginUser($this->admin()); $crawler = $this->client->request('GET', '/admin/access-codes/new', server: ['HTTP_HX-Request' => 'true']); self::assertSame(date('Y-m-d'), $crawler->filter('#access_code_form_validFrom')->attr('value')); } public function testAMissingLabelIsRejected(): void { $this->client->loginUser($this->admin()); $response = $this->submit('/admin/access-codes/new', 'access_code_form', [ 'label' => '', 'code' => '4711', 'validFrom' => '2026-10-01', 'visibility' => 'everyone', ]); self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); self::assertCount(0, $this->em->getRepository(AccessCode::class)->findAll()); } public function testEditingACodeIncludingItsVisibility(): void { $code = $this->code('Serverraum', '4711'); $this->client->loginUser($this->admin()); $response = $this->submit(\sprintf('/admin/access-codes/%d/edit', $code->getId()), 'access_code_form', [ 'label' => 'Serverraum', 'description' => '', 'code' => '9999', 'validFrom' => $code->getValidFrom()->format('Y-m-d'), 'visibility' => 'admin', ]); self::assertSame('/admin/access-codes', $response->headers->get('HX-Redirect')); $code = $this->reload($code); self::assertSame('9999', $code->getCode()); self::assertSame(CodeVisibility::ADMIN, $code->getVisibility()); self::assertNull($code->getDescription()); } /** * Two codes for the same door is the ordinary way to rotate one, so nothing may refuse it. */ public function testASecondCodeForTheSameDoorIsAccepted(): void { $this->code('Aufzug', '4711'); $this->client->loginUser($this->admin()); $this->submit('/admin/access-codes/new', 'access_code_form', [ 'label' => 'Aufzug', 'code' => '9999', 'validFrom' => '2026-12-01', 'visibility' => 'everyone', ]); self::assertCount(2, $this->em->getRepository(AccessCode::class)->findBy(['label' => 'Aufzug'])); } public function testDeletingACode(): void { $code = $this->code('Aufzug', '4711'); $id = $code->getId(); $this->client->loginUser($this->admin()); $uri = \sprintf('/admin/access-codes/%d/delete', $id); $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); self::assertSame('/admin/access-codes', $this->client->getResponse()->headers->get('HX-Redirect')); $this->em->clear(); self::assertNull($this->em->find(AccessCode::class, $id)); } public function testDeletingACodeNeedsAValidToken(): void { $code = $this->code('Aufzug', '4711'); $this->client->loginUser($this->admin()); $this->client->request('POST', \sprintf('/admin/access-codes/%d/delete', $code->getId()), ['_token' => 'whatever'], server: ['HTTP_HX-Request' => 'true']); self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); $this->em->clear(); self::assertNotNull($this->em->find(AccessCode::class, $code->getId())); } /** * 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. */ private function reload(AccessCode $code): AccessCode { $fresh = $this->em->find(AccessCode::class, $code->getId()); self::assertNotNull($fresh); return $fresh; } private function code(string $label, string $value, string $validFrom = 'today'): AccessCode { $code = new AccessCode($label, $value, new \DateTimeImmutable($validFrom)); $this->em->persist($code); $this->em->flush(); return $code; } 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; } }