feat: door access codes display and admin crud
This commit is contained in:
1 parent
91419294b8
commit
afa3f17d77
20 files changed
+1428
No files matched your search
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
use App\Entity\AccessCode;
|
||||
use App\Entity\User;
|
||||
use App\Enum\CodeVisibility;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Maintaining the access codes: the table of everything there is, and the three writes.
|
||||
*
|
||||
* The quick look at /access-codes is the other half and is tested in AccessCodeControllerTest --
|
||||
* what it shows is one card per door, what this shows is every row including the ones that page
|
||||
* deliberately leaves out.
|
||||
*/
|
||||
class AccessCodeAdminControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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('[email protected]'));
|
||||
|
||||
$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<string, string> $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('[email protected]', ['ROLE_ADMIN']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user