feat: add the booking domain layer
This commit is contained in:
1 parent
ef8a88ed2a
commit
cb407477bf
13 files changed
+693
-6
No files matched your search
@@ -30,10 +30,10 @@ DEFAULT_URI=http://localhost
|
||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||
#
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
|
||||
# MariaDB 11.x, as provided by DDEV. The mysql:// scheme is just DBAL's pdo_mysql alias --
|
||||
# serverVersion is what selects MariaDBPlatform over MySQLPlatform, so keep it in sync with
|
||||
# the container. DDEV writes the same value into the gitignored .env.local.
|
||||
DATABASE_URL="mysql://db:db@db:3306/db?charset=utf8mb4&serverVersion=11.8.0-mariadb"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
|
||||
@@ -10,8 +10,6 @@ doctrine:
|
||||
orm:
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore
|
||||
identity_generation_preferences:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Initial schema: users mirrored from myep, bookable resources, and bookings.
|
||||
*/
|
||||
final class Version20260913125102 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create user, resource and booking tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE booking (id INT AUTO_INCREMENT NOT NULL, date DATE NOT NULL, created_at DATETIME NOT NULL, user_id INT NOT NULL, resource_id INT NOT NULL, UNIQUE INDEX uniq_booking_resource_date (resource_id, date), INDEX IDX_E00CEDDEA76ED395 (user_id), INDEX IDX_E00CEDDE89329D25 (resource_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE resource (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(255) NOT NULL, label VARCHAR(50) NOT NULL, svg_element_id VARCHAR(50) DEFAULT NULL, active TINYINT NOT NULL, UNIQUE INDEX uniq_resource_label (type, label), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, name VARCHAR(100) NOT NULL, roles JSON NOT NULL, UNIQUE INDEX uniq_user_email (email), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL, available_at DATETIME NOT NULL, delivered_at DATETIME DEFAULT NULL, INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDEA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDE89329D25 FOREIGN KEY (resource_id) REFERENCES resource (id) ON DELETE RESTRICT');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDEA76ED395');
|
||||
$this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDE89329D25');
|
||||
$this->addSql('DROP TABLE booking');
|
||||
$this->addSql('DROP TABLE resource');
|
||||
$this->addSql('DROP TABLE `user`');
|
||||
$this->addSql('DROP TABLE messenger_messages');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* One person holding one resource for one whole day.
|
||||
*
|
||||
* The unique constraint is the actual guard against double-booking -- checking first and then
|
||||
* inserting is racy, so callers insert and catch UniqueConstraintViolationException instead.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: BookingRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_booking_resource_date', columns: ['resource_id', 'date'])]
|
||||
class Booking
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
private Resource $resource;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
private \DateTimeImmutable $date;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(User $user, Resource $resource, \DateTimeImmutable $date)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->resource = $resource;
|
||||
// Normalised to midnight so the unique constraint cannot be sidestepped by a time part.
|
||||
$this->date = $date->setTime(0, 0);
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getResource(): Resource
|
||||
{
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
public function getDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function belongsTo(User $user): bool
|
||||
{
|
||||
return $this->user->getId() === $user->getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ResourceRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* A bookable thing: a desk or a parking spot.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: ResourceRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_resource_label', columns: ['type', 'label'])]
|
||||
class Resource
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(enumType: ResourceType::class)]
|
||||
private ResourceType $type;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Assert\NotBlank]
|
||||
private string $label;
|
||||
|
||||
/**
|
||||
* Id of the matching element in the hand-authored floor plan SVG. Null for anything that is
|
||||
* bookable but not drawn on the plan.
|
||||
*/
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
private ?string $svgElementId = null;
|
||||
|
||||
/**
|
||||
* Retired resources stay in the table so historical bookings keep resolving; they are just
|
||||
* not offered any more.
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private bool $active = true;
|
||||
|
||||
public function __construct(ResourceType $type, string $label, ?string $svgElementId = null)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->label = $label;
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): ResourceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $label): void
|
||||
{
|
||||
$this->label = $label;
|
||||
}
|
||||
|
||||
public function getSvgElementId(): ?string
|
||||
{
|
||||
return $this->svgElementId;
|
||||
}
|
||||
|
||||
public function setSvgElementId(?string $svgElementId): void
|
||||
{
|
||||
$this->svgElementId = $svgElementId;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
}
|
||||
|
||||
public function setActive(bool $active): void
|
||||
{
|
||||
$this->active = $active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
enum ResourceType: string
|
||||
{
|
||||
case DESK = 'desk';
|
||||
case PARKING = 'parking';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DESK => 'Schreibtisch',
|
||||
self::PARKING => 'Parkplatz',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* A local mirror of an identity held by myep.
|
||||
*
|
||||
* There is no password: authentication happens against myep over OAuth2, and ephub reads
|
||||
* /api/userinfo once at login and then relies on its own session.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_user_email', columns: ['email'])]
|
||||
class User implements UserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
private string $email;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
private string $name;
|
||||
|
||||
/**
|
||||
* Always filtered through a local whitelist before being stored -- the identity provider's
|
||||
* claim is never trusted verbatim.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private array $roles = [];
|
||||
|
||||
public function __construct(string $email, string $name)
|
||||
{
|
||||
$this->email = $email;
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
return array_values(array_unique([...$this->roles, 'ROLE_USER']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function setRoles(array $roles): void
|
||||
{
|
||||
$this->roles = $roles;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Booking>
|
||||
*/
|
||||
class BookingRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Booking::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything booked on one day, for colouring the floor plan.
|
||||
*
|
||||
* Team view is in scope, so the booking holder's name is shown on occupied resources --
|
||||
* hence the joins are eager, or the plan would fire a query per drawn element.
|
||||
*
|
||||
* @return list<Booking>
|
||||
*/
|
||||
public function findForDate(\DateTimeImmutable $date): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->addSelect('u', 'r')
|
||||
->join('b.user', 'u')
|
||||
->join('b.resource', 'r')
|
||||
->andWhere('b.date = :date')
|
||||
->setParameter('date', $date->setTime(0, 0))
|
||||
->orderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* One person's bookings over a window, for the "my bookings" list.
|
||||
*
|
||||
* @return list<Booking>
|
||||
*/
|
||||
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->addSelect('r')
|
||||
->join('b.resource', 'r')
|
||||
->andWhere('b.user = :user')
|
||||
->andWhere('b.date BETWEEN :from AND :to')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('from', $from->setTime(0, 0))
|
||||
->setParameter('to', $to->setTime(0, 0))
|
||||
->orderBy('b.date', 'ASC')
|
||||
->addOrderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as
|
||||
* occupancy, and the same query team view reads.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('b')
|
||||
->select('b.date AS date', 'COUNT(b.id) AS total')
|
||||
->andWhere('b.date BETWEEN :from AND :to')
|
||||
->setParameter('from', $from->setTime(0, 0))
|
||||
->setParameter('to', $to->setTime(0, 0))
|
||||
->groupBy('b.date')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[$row['date']->format('Y-m-d')] = (int) $row['total'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public function findOneForUserAndDate(User $user, \DateTimeImmutable $date): ?Booking
|
||||
{
|
||||
return $this->findOneBy(['user' => $user, 'date' => $date->setTime(0, 0)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Resource>
|
||||
*/
|
||||
class ResourceRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Resource::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Resource>
|
||||
*/
|
||||
public function findActiveByType(ResourceType $type): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->andWhere('r.active = true')
|
||||
->andWhere('r.type = :type')
|
||||
->setParameter('type', $type)
|
||||
->orderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Resource>
|
||||
*/
|
||||
public function findAllActive(): array
|
||||
{
|
||||
return $this->createQueryBuilder('r')
|
||||
->andWhere('r.active = true')
|
||||
->orderBy('r.type', 'ASC')
|
||||
->addOrderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<User>
|
||||
*/
|
||||
class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
public function findOneByEmail(string $email): ?User
|
||||
{
|
||||
return $this->findOneBy(['email' => $email]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class BookingTest extends TestCase
|
||||
{
|
||||
public function testTheDateIsNormalisedToMidnightSoTheUniqueConstraintCannotBeSidestepped(): void
|
||||
{
|
||||
$booking = new Booking(
|
||||
new User('[email protected]', 'A'),
|
||||
new Resource(ResourceType::DESK, 'D1'),
|
||||
new \DateTimeImmutable('2026-09-13 14:37:11'),
|
||||
);
|
||||
|
||||
self::assertSame('2026-09-13 00:00:00', $booking->getDate()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class UserTest extends TestCase
|
||||
{
|
||||
public function testEveryUserGetsRoleUserWithoutItBeingStored(): void
|
||||
{
|
||||
self::assertSame(['ROLE_USER'], (new User('[email protected]', 'A'))->getRoles());
|
||||
}
|
||||
|
||||
public function testRoleUserIsNotDuplicatedWhenAlreadyGranted(): void
|
||||
{
|
||||
$user = new User('[email protected]', 'A');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_EMPLOYEE']);
|
||||
|
||||
self::assertSame(['ROLE_USER', 'ROLE_EMPLOYEE'], $user->getRoles());
|
||||
}
|
||||
|
||||
public function testTheEmailIsTheSecurityIdentifier(): void
|
||||
{
|
||||
self::assertSame('[email protected]', (new User('[email protected]', 'A'))->getUserIdentifier());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Hits the real database, because the behaviour under test is the database's: the unique
|
||||
* constraint is what actually prevents double-booking.
|
||||
*
|
||||
* Each test runs inside a transaction that is rolled back afterwards.
|
||||
*/
|
||||
class BookingRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private BookingRepository $bookings;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->bookings = self::getContainer()->get(BookingRepository::class);
|
||||
$this->em->getConnection()->beginTransaction();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->em->getConnection()->rollBack();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheSameResourceCannotBeBookedTwiceOnOneDay(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
|
||||
$this->em->flush();
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
|
||||
|
||||
// The controller catches exactly this and re-renders the fragment with an inline error,
|
||||
// rather than pre-checking, which would be racy.
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testTheSameResourceOnAnotherDayIsFine(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$user = $this->user('[email protected]');
|
||||
|
||||
$this->persist(new Booking($user, $desk, new \DateTimeImmutable('2026-10-01')));
|
||||
$this->persist(new Booking($user, $desk, new \DateTimeImmutable('2026-10-02')));
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
|
||||
}
|
||||
|
||||
public function testATimeComponentDoesNotCreateASecondSlotForTheSameDay(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 08:00')));
|
||||
$this->em->flush();
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testFindForDateCarriesTheBookingHolderForTheTeamView(): void
|
||||
{
|
||||
$this->persist(new Booking($this->user('[email protected]', 'Anna'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
$bookings = $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01'));
|
||||
|
||||
self::assertCount(1, $bookings);
|
||||
self::assertSame('Anna', $bookings[0]->getUser()->getName());
|
||||
self::assertSame('D1', $bookings[0]->getResource()->getLabel());
|
||||
}
|
||||
|
||||
public function testCountPerDateInRangeIsKeyedByDay(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
$this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->persist(new Booking($this->user('[email protected]'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->persist(new Booking($user, $this->resource('P1', ResourceType::PARKING), new \DateTimeImmutable('2026-10-03')));
|
||||
$this->em->flush();
|
||||
|
||||
$counts = $this->bookings->countPerDateInRange(new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-31'));
|
||||
|
||||
self::assertSame(['2026-10-01' => 2, '2026-10-03' => 1], $counts);
|
||||
}
|
||||
|
||||
public function testCountPerDateIgnoresDaysOutsideTheWindow(): void
|
||||
{
|
||||
$this->persist(new Booking($this->user('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-11-05')));
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame([], $this->bookings->countPerDateInRange(new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-31')));
|
||||
}
|
||||
|
||||
public function testFindForUserInRangeOnlyReturnsThatPersonsBookings(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->persist(new Booking($this->user('[email protected]'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
|
||||
$mine = $this->bookings->findForUserInRange($anna, new \DateTimeImmutable('2026-10-01'), new \DateTimeImmutable('2026-10-31'));
|
||||
|
||||
self::assertCount(1, $mine);
|
||||
self::assertSame('D1', $mine[0]->getResource()->getLabel());
|
||||
}
|
||||
|
||||
private function persist(object $entity): void
|
||||
{
|
||||
$this->em->persist($entity);
|
||||
}
|
||||
|
||||
private function user(string $email, string $name = 'Test'): User
|
||||
{
|
||||
$user = new User($email, $name);
|
||||
$this->em->persist($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label);
|
||||
$this->em->persist($resource);
|
||||
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user