feat: all-season assignments

This commit is contained in:
Björn Fromme authored and Björn Fromme committed 2026-09-26 12:13:16 +02:00
1 parent 675cec1b69
commit cf876ed224
119 files changed
+6194 -294

No files matched your search

+46
View File
@@ -71,6 +71,52 @@ class SeasonCalendarTest extends TestCase
(new SeasonCalendar('xx-yy', '04-01'))->seasonYearFor(new \DateTimeImmutable('2025-01-01'));
}
/**
* @dataProvider currentOrUpcomingDates
*/
public function testCurrentOrUpcomingSeasonYear(string $date, int $expected): void
{
$this->assertSame(
$expected,
$this->winter()->currentOrUpcomingSeasonYear(new \DateTimeImmutable($date))
);
}
public static function currentOrUpcomingDates(): iterable
{
yield 'inside the season' => ['2024-12-20', 2024];
yield 'last day of the season' => ['2025-04-01', 2024];
yield 'day after the season ends falls forward' => ['2025-04-02', 2025];
yield 'high summer falls forward' => ['2025-07-15', 2025];
yield 'day before the next season starts' => ['2025-10-31', 2025];
yield 'first day of the next season' => ['2025-11-01', 2025];
yield 'off-season in january is impossible for this window' => ['2025-01-05', 2024];
}
public function testCurrentOrUpcomingSeasonYearFallsForwardAcrossTheTurnOfTheYear(): void
{
// A window that ends before it starts in the same calendar year is the wrapping case;
// one that does not wrap has its off-season on both sides of the window instead.
$summer = new SeasonCalendar('05-01', '09-30');
$this->assertSame(2025, $summer->currentOrUpcomingSeasonYear(new \DateTimeImmutable('2025-03-01')));
$this->assertSame(2025, $summer->currentOrUpcomingSeasonYear(new \DateTimeImmutable('2025-06-01')));
$this->assertSame(2026, $summer->currentOrUpcomingSeasonYear(new \DateTimeImmutable('2025-11-20')));
}
public function testWindowFor(): void
{
[$start, $end] = $this->winter()->windowFor(2024);
$this->assertSame('2024-11-01', $start->format('Y-m-d'));
$this->assertSame('2025-04-01', $end->format('Y-m-d'));
[$start, $end] = (new SeasonCalendar('05-01', '09-30'))->windowFor(2025);
$this->assertSame('2025-05-01', $start->format('Y-m-d'));
$this->assertSame('2025-09-30', $end->format('Y-m-d'));
}
/**
* @dataProvider provideUpcomingSeasonDates
*/
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\SeasonTask;
use App\Config\SeasonTaskProvision;
use PHPUnit\Framework\TestCase;
/**
* The enum is the single source of truth for the checklist, so what is pinned here is the
* agreed entry list itself: adding or changing an entry is a decision the office makes, not
* something that should slip in unnoticed.
*/
class SeasonTaskTest extends TestCase
{
public function testEveryCaseResolvesALabelAndADistinctKey(): void
{
$keys = [];
$labels = [];
foreach (SeasonTask::cases() as $case) {
$this->assertNotSame('', $case->label(), sprintf('%s has no label', $case->name));
$keys[] = $case->value;
$labels[] = $case->label();
}
$this->assertSame($keys, array_unique($keys));
$this->assertSame($labels, array_unique($labels));
}
/**
* @dataProvider entries
*/
public function testTheAgreedEntryList(
SeasonTask $task,
string $label,
SeasonTaskProvision $provides,
bool $requiresReturn,
): void {
$this->assertSame($label, $task->label());
$this->assertSame($provides, $task->provides());
$this->assertSame($requiresReturn, $task->requiresReturn());
}
public static function entries(): iterable
{
yield [SeasonTask::MASTER_DATA, 'Stammdaten', SeasonTaskProvision::STANDARD, true];
yield [SeasonTask::VEHICLE, 'KfZ', SeasonTaskProvision::STANDARD, true];
yield [SeasonTask::CONTRACT, 'Vertrag', SeasonTaskProvision::INDIVIDUAL, true];
yield [SeasonTask::ADDITIONAL_CONTRACT, 'Zusatzvertrag', SeasonTaskProvision::INDIVIDUAL, true];
yield [SeasonTask::SAAS_REGISTERED, 'SaaS angemeldet', SeasonTaskProvision::NONE, false];
yield [SeasonTask::MUNICIPALITY_REGISTERED, 'Gemeinde angemeldet', SeasonTaskProvision::NONE, false];
}
public function testAnEntryThatHandsNothingOutNeverExpectsSomethingBack(): void
{
// the two are independent properties, but "nothing out and something back" would be a
// task the teamer is reminded about without ever being given anything to return
foreach (SeasonTask::cases() as $case) {
if (SeasonTaskProvision::NONE === $case->provides()) {
$this->assertFalse($case->requiresReturn(), sprintf('%s expects a return', $case->name));
}
}
}
public function testRequiringReturnIsTheTeamerFacingSubsetInEnumOrder(): void
{
$this->assertSame(
[
SeasonTask::MASTER_DATA,
SeasonTask::VEHICLE,
SeasonTask::CONTRACT,
SeasonTask::ADDITIONAL_CONTRACT,
],
SeasonTask::requiringReturn()
);
}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Tests\Entity;
use App\Entity\JobProfile;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use PHPUnit\Framework\TestCase;
class SeasonAssignmentTest extends TestCase
{
public function testANewSeasonAssignmentCarriesItsOwnUuid(): void
{
$first = new SeasonAssignment();
$second = new SeasonAssignment();
$this->assertMatchesRegularExpression('/^[0-9a-f-]{36}$/', $first->getUuid());
$this->assertNotSame($first->getUuid(), $second->getUuid());
}
public function testANewSeasonAssignmentIsNotDeleted(): void
{
$this->assertFalse((new SeasonAssignment())->isDeleted());
}
/**
* The list only ever hides soft-deleted records, so deleting must not detach the teamer -
* the placement has to stay readable for whoever looks at it afterwards.
*/
public function testSoftDeletingKeepsTheRecordIntact(): void
{
$teamer = (new Teamer())->setFirstName('Ada')->setLastName('Musterfrau');
$seasonAssignment = (new SeasonAssignment())->setTeamer($teamer);
$seasonAssignment->setDeleted();
$this->assertTrue($seasonAssignment->isDeleted());
$this->assertSame($teamer, $seasonAssignment->getTeamer());
}
public function testTheInverseSideIsKeptInSync(): void
{
$teamer = new Teamer();
$seasonAssignment = new SeasonAssignment();
$teamer->addSeasonAssignment($seasonAssignment);
$this->assertCount(1, $teamer->getSeasonAssignments());
$this->assertSame($teamer, $seasonAssignment->getTeamer());
// adding the same one twice must not duplicate it
$teamer->addSeasonAssignment($seasonAssignment);
$this->assertCount(1, $teamer->getSeasonAssignments());
$teamer->removeSeasonAssignment($seasonAssignment);
$this->assertCount(0, $teamer->getSeasonAssignments());
$this->assertNull($seasonAssignment->getTeamer());
}
/**
* Administrative users lay the season's assignments out first and place teamers on them
* later, so an assignment without a teamer is a normal state rather than a half built one.
*/
public function testAnAssignmentStartsWithoutATeamer(): void
{
$seasonAssignment = new SeasonAssignment();
$this->assertNull($seasonAssignment->getTeamer());
}
public function testATeamerCanBePlacedAndRemovedAgain(): void
{
$teamer = (new Teamer())->setFirstName('Ada')->setLastName('Musterfrau');
$seasonAssignment = new SeasonAssignment();
$seasonAssignment->setTeamer($teamer);
$this->assertSame($teamer, $seasonAssignment->getTeamer());
$seasonAssignment->setTeamer(null);
$this->assertNull($seasonAssignment->getTeamer());
}
/**
* The period and the house live on the assignment itself - it is not cut from a travel, so
* there is no Destination to read them from.
*/
public function testItCarriesItsOwnPeriodAndHouse(): void
{
$seasonAssignment = (new SeasonAssignment())
->setTeamer((new Teamer())->setFirstName('Ada')->setLastName('Musterfrau'))
->setDateFrom(new \DateTimeImmutable('2026-11-01'))
->setDateTo(new \DateTimeImmutable('2027-04-30'))
->setHotelCode('AGR')
->setJobProfile((new JobProfile())->setName('Guide'))
;
$this->assertSame('2026-11-01', $seasonAssignment->getDateFrom()->format('Y-m-d'));
$this->assertSame('2027-04-30', $seasonAssignment->getDateTo()->format('Y-m-d'));
$this->assertSame('AGR', $seasonAssignment->getHotelCode());
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Form\AutosizeTextareaType;
use Symfony\Component\Form\Test\TypeTestCase;
/**
* The Stimulus wiring is what this type exists for, so it is asserted here rather than
* repeated in every form type that uses it.
*
* The rows attribute is deliberately not part of it: textarea_autosize_controller.js
* overwrites rows on connect, which is why the floor travels as min_rows instead.
*/
class AutosizeTextareaTypeTest extends TypeTestCase
{
public function testTheControllerAndItsResizeActionAreWiredUp(): void
{
$attr = $this->attr();
$this->assertSame('textarea-autosize', $attr['data-controller']);
$this->assertSame('textarea-autosize#resize', $attr['data-action']);
}
/**
* One row is what the controller itself defaults to, so a field that says nothing
* renders exactly as it did before this type existed.
*/
public function testTheFloorDefaultsToASingleRow(): void
{
$this->assertSame(1, $this->attr()['data-textarea-autosize-min-rows-value']);
}
public function testMinRowsReachesTheController(): void
{
$this->assertSame(12, $this->attr(['min_rows' => 12])['data-textarea-autosize-min-rows-value']);
}
/**
* A field needing a second Stimulus controller on the same element has to compose the
* list itself, so its own attributes must win over the defaults.
*/
public function testTheCallersOwnAttributesWin(): void
{
$attr = $this->attr(['attr' => ['data-controller' => 'textarea-autosize other', 'placeholder' => 'x']]);
$this->assertSame('textarea-autosize other', $attr['data-controller']);
$this->assertSame('x', $attr['placeholder']);
$this->assertSame('textarea-autosize#resize', $attr['data-action']);
}
/**
* @return array<string, mixed>
*/
private function attr(array $options = []): array
{
return $this->factory->create(AutosizeTextareaType::class, null, $options)->createView()->vars['attr'];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\TeamerFilterDto;
use PHPUnit\Framework\TestCase;
/**
* The all-season teamer list is a second view onto the same filter DTO: it is always scoped,
* but that scope is not something the admin chose. AbstractFilterDto counts every property that
* differs from a fresh instance, so an ordinary property would make the list permanently claim
* "1 Filter aktiv" and offer a Reset button that resets nothing. The underscore keeps it out.
*/
class TeamerFilterDtoTest extends TestCase
{
public function testTheScopeDoesNotCountAsAnActiveFilter(): void
{
$filterDto = (new TeamerFilterDto())->setAllSeason(true);
$this->assertTrue($filterDto->isAllSeason());
$this->assertSame(0, $filterDto->getActiveFiltersCount());
$this->assertFalse($filterDto->isActive());
}
public function testAFilterOnTopOfTheScopeStillCounts(): void
{
$filterDto = (new TeamerFilterDto())
->setAllSeason(true)
->setName('Fromme')
;
$this->assertSame(1, $filterDto->getActiveFiltersCount());
$this->assertTrue($filterDto->isActive());
}
public function testTheListIsUnscopedByDefault(): void
{
$this->assertFalse((new TeamerFilterDto())->isAllSeason());
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Repository\SeasonAssignmentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The rule that a teamer gets one season assignment per season is only as good as the window the
* query asks for, so the conditions are compiled and pinned here - the suite has no fixtures.
*/
class SeasonAssignmentRepositoryTest extends KernelTestCase
{
public function testTheSeasonWindowQueryBucketsByTheAssignmentsFirstDay(): void
{
$dql = $this->createInSeasonWindowQueryBuilder()->getQuery()->getDQL();
// both bounds on dateFrom: a placement running past the last day of the season still
// belongs to that season, so dateTo must not be part of the test
$this->assertStringContainsString('season_assignment.dateFrom >= :windowStart', $dql);
$this->assertStringContainsString('season_assignment.dateFrom <= :windowEnd', $dql);
$this->assertStringNotContainsString('dateTo', $dql);
}
/**
* The period is the assignment's own, so there is no second row left to join - and none that
* could be soft-deleted underneath a live placement.
*/
public function testTheSeasonWindowQueryNeedsNoJoin(): void
{
$dql = $this->createInSeasonWindowQueryBuilder()->getQuery()->getDQL();
$this->assertStringContainsString('season_assignment.deletedAt IS NULL', $dql);
$this->assertStringNotContainsString('destination', $dql);
}
public function testTheSeasonWindowQueryOnlyExcludesARowWhenEditingOne(): void
{
$this->assertStringNotContainsString(
'season_assignment.id <> :excludeId',
$this->createInSeasonWindowQueryBuilder()->getQuery()->getDQL()
);
$this->assertStringContainsString(
'season_assignment.id <> :excludeId',
$this->createInSeasonWindowQueryBuilder(42)->getQuery()->getDQL()
);
}
private function createInSeasonWindowQueryBuilder(?int $excludeId = null): \Doctrine\ORM\QueryBuilder
{
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
/** @var SeasonAssignmentRepository $repository */
$repository = $entityManager->getRepository(SeasonAssignment::class);
return $repository->createInSeasonWindowQueryBuilder(
new Teamer(),
new \DateTimeImmutable('2026-11-01'),
new \DateTimeImmutable('2027-04-01'),
$excludeId
);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\SeasonAssignmentTask;
use App\Repository\SeasonAssignmentTaskRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The reminders are only as good as the set of rows they start from, so the conditions are
* compiled and pinned here - the suite has no fixtures.
*
* Note what is deliberately absent: the deadlines. They are a relative offset off the
* assignment's first day, and month arithmetic is not invertible day by day, so they are
* resolved in PHP rather than reproduced in DQL.
*/
class SeasonAssignmentTaskRepositoryTest extends KernelTestCase
{
public function testOpenTasksExcludeDoneOnesAndDeletedRecords(): void
{
$dql = $this->createOpenQueryBuilder()->getQuery()->getDQL();
$this->assertStringContainsString('task.done = :done', $dql);
$this->assertStringContainsString('season_assignment.deletedAt IS NULL', $dql);
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
// the period is the assignment's own, so there is no destination left to join
$this->assertStringNotContainsString('destination', $dql);
}
/**
* An inner join, not a left one: an assignment the office has laid out but not staffed yet
* has nobody to remind and nobody who could be late.
*/
public function testAnUnstaffedAssignmentIsLeftOut(): void
{
$dql = $this->createOpenQueryBuilder()->getQuery()->getDQL();
$this->assertStringContainsString('INNER JOIN season_assignment.teamer teamer', $dql);
$this->assertStringNotContainsString('LEFT JOIN', $dql);
}
public function testTheSeasonWindowIsOptional(): void
{
$unscoped = $this->createOpenQueryBuilder()->getQuery()->getDQL();
$scoped = $this
->createOpenQueryBuilder(new \DateTimeImmutable('2026-11-01'), new \DateTimeImmutable('2027-04-01'))
->getQuery()
->getDQL()
;
$this->assertStringNotContainsString('windowStart', $unscoped);
$this->assertStringContainsString('season_assignment.dateFrom >= :windowStart', $scoped);
$this->assertStringContainsString('season_assignment.dateFrom <= :windowEnd', $scoped);
}
private function createOpenQueryBuilder(
?\DateTimeImmutable $windowStart = null,
?\DateTimeImmutable $windowEnd = null,
): QueryBuilder {
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
/** @var SeasonAssignmentTaskRepository $repository */
$repository = $entityManager->getRepository(SeasonAssignmentTask::class);
return $repository->createOpenQueryBuilder($windowStart, $windowEnd);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Model\TeamerFilterDto;
use App\Repository\TeamerRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The all-season list is the ordinary teamer list under a scope, so what separates the two is a
* single condition in the shared query builder. Compiled here so the scope cannot quietly stop
* being applied - an unscoped "Saisonteamer" list would silently show the whole team.
*/
class TeamerRepositoryTest extends KernelTestCase
{
public function testTheScopedListRestrictsToAllSeasonTeamers(): void
{
$query = $this->getRepository()->getListQuery((new TeamerFilterDto())->setAllSeason(true));
$this->assertStringContainsString('teamer.allSeason = :allSeason', $query->getDQL());
$this->assertTrue($query->getParameter('allSeason')->getValue());
}
public function testTheGeneralListIsNotRestricted(): void
{
$query = $this->getRepository()->getListQuery(new TeamerFilterDto());
$this->assertStringNotContainsString('teamer.allSeason', $query->getDQL());
$this->assertNull($query->getParameter('allSeason'));
}
/**
* The mailing resolves its recipients through the same builder, so a mailing started from
* the all-season list must not be able to reach the rest of the team.
*/
public function testTheMailingRecipientsCarryTheSameScope(): void
{
$repository = $this->getRepository();
$reflection = new \ReflectionMethod($repository, 'createListQueryBuilder');
$queryBuilder = $reflection->invoke($repository, (new TeamerFilterDto())->setAllSeason(true));
$this->assertStringContainsString('teamer.allSeason = :allSeason', $queryBuilder->getDQL());
}
/**
* The season assignment form picks its teamer through this endpoint, so an unrestricted
* suggestion list would let an admin place a short term teamer on a season assignment.
*/
public function testTheAutocompleteCanBeRestrictedToAllSeasonTeamers(): void
{
$dql = $this->createAutocompletionQueryBuilder(true)->getDQL();
$this->assertStringContainsString('teamer.allSeason = :allSeason', $dql);
}
public function testTheAutocompleteIsUnrestrictedByDefault(): void
{
$dql = $this->createAutocompletionQueryBuilder(false)->getDQL();
$this->assertStringNotContainsString('teamer.allSeason', $dql);
$this->assertStringContainsString('teamer.deletedAt IS NULL', $dql);
}
private function createAutocompletionQueryBuilder(bool $allSeasonOnly): QueryBuilder
{
$repository = $this->getRepository();
$reflection = new \ReflectionMethod($repository, 'createAutocompletionQueryBuilder');
return $reflection->invoke($repository, '', $allSeasonOnly);
}
private function getRepository(): TeamerRepository
{
self::bootKernel();
return self::getContainer()->get(TeamerRepository::class);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Upload;
use App\Repository\UploadRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* The document picker decides which files an administrative setting may point at, so the type
* restriction is pinned here: unrestricted it offers every upload in the system, which would let
* a setting reference a teamer's Honorarvertrag or Beleg.
*/
class UploadRepositoryTest extends KernelTestCase
{
public function testTheAutocompleteOnlyOffersTheGlobalLibrary(): void
{
$qb = $this->createAutocompletionQueryBuilder();
$this->assertStringContainsString('upload.type = :type', $qb->getDQL());
$this->assertSame(
Upload::TYPE_DOCUMENT,
$qb->getQuery()->getParameter('type')->getValue()
);
}
public function testTheAutocompleteStillSearchesBothNames(): void
{
$dql = $this->createAutocompletionQueryBuilder()->getDQL();
$this->assertStringContainsString('upload.originalFilename LIKE :filename', $dql);
$this->assertStringContainsString('upload.displayName LIKE :filename', $dql);
}
private function createAutocompletionQueryBuilder(): QueryBuilder
{
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
/** @var UploadRepository $repository */
$repository = $entityManager->getRepository(Upload::class);
$reflection = new \ReflectionMethod($repository, 'createAutocompletionQueryBuilder');
return $reflection->invoke($repository, '');
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Entity\Upload;
use App\Entity\User;
use App\Security\Voter\UploadVoter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* A document the office issues for one teamer is the first upload in this app that belongs to a
* teamer without being owned by them, so the rules around it are the ones worth pinning: the
* intended teamer gets in, every other teamer does not, and the library stays as open as it was.
*/
class UploadVoterTest extends TestCase
{
private Security&MockObject $security;
protected function setUp(): void
{
$this->security = $this->createMock(Security::class);
$this->security
->method('isGranted')
->willReturnCallback(static fn (string $role): bool => 'ROLE_TEAMER' === $role)
;
}
public function testATeamerMayDownloadASeasonDocumentIssuedToThem(): void
{
$teamer = new Teamer();
$this->assertSame(
VoterInterface::ACCESS_GRANTED,
$this->vote(UploadVoter::DOWNLOAD, $this->seasonDocument($teamer), $teamer)
);
}
public function testATeamerMayNotDownloadAnotherTeamersSeasonDocument(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote(UploadVoter::DOWNLOAD, $this->seasonDocument(new Teamer()), new Teamer())
);
}
public function testASeasonDocumentOnAnUnstaffedAssignmentReachesNoTeamer(): void
{
$upload = (new Upload())
->setType(Upload::TYPE_SEASON_DOCUMENT_OUT)
->setOwner(new User())
;
$upload->setSeasonAssignment(new SeasonAssignment());
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote(UploadVoter::DOWNLOAD, $upload, new Teamer())
);
}
/**
* Downloading is all the new rule grants: the file stays the office's, so the teamer it was
* issued to may not delete it or reach it through any other attribute.
*
* @dataProvider otherAttributes
*/
public function testTheNewRuleGrantsNothingButDownloading(string $attribute): void
{
$teamer = new Teamer();
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($attribute, $this->seasonDocument($teamer), $teamer)
);
}
/**
* @return array<string, array{string}>
*/
public static function otherAttributes(): array
{
return [
'viewing' => [UploadVoter::VIEW],
'deleting' => [UploadVoter::DELETE],
];
}
/**
* The library is deliberately open to all staff, which is exactly why an individually issued
* document must never carry its type - so the blanket rule has to stay untouched.
*/
public function testTheLibraryStaysReadableByEveryTeamer(): void
{
$upload = (new Upload())
->setType(Upload::TYPE_DOCUMENT)
->setOwner(new User())
;
$this->assertSame(
VoterInterface::ACCESS_GRANTED,
$this->vote(UploadVoter::DOWNLOAD, $upload, new Teamer())
);
}
private function seasonDocument(Teamer $teamer): Upload
{
$seasonAssignment = (new SeasonAssignment())->setTeamer($teamer);
$upload = (new Upload())
->setType(Upload::TYPE_SEASON_DOCUMENT_OUT)
->setOwner(new User())
;
$upload->setSeasonAssignment($seasonAssignment);
return $upload;
}
private function vote(string $attribute, Upload $upload, Teamer $teamer): int
{
$token = $this->createMock(TokenInterface::class);
$token->method('getUser')->willReturn((new User())->setTeamer($teamer));
return (new UploadVoter($this->security))->vote($token, $upload, [$attribute]);
}
}
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Common;
use App\Model\TeamerFilterDto;
use App\Service\Common\TeamerFilterHandler;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
/**
* Two teamer lists share one filter form, so the only thing keeping them apart is the session
* bucket the settings are stored under. Without the split, filtering the Teamübersicht would
* silently narrow the Saisonteamer list as well - and the other way round.
*/
class TeamerFilterHandlerTest extends TestCase
{
public function testEachListKeepsItsOwnFilterSettings(): void
{
$handler = $this->createHandler();
$this->save($handler->setScope(null), 'Fromme');
$this->save($handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON), 'Musterfrau');
$this->assertSame('Fromme', $handler->setScope(null)->getFilterSettings()->getName());
$this->assertSame(
'Musterfrau',
$handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->getFilterSettings()->getName()
);
}
public function testResettingOneListLeavesTheOtherAlone(): void
{
$handler = $this->createHandler();
$this->save($handler->setScope(null), 'Fromme');
$this->save($handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON), 'Musterfrau');
$handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->resetFilterSettings();
$this->assertSame('Fromme', $handler->setScope(null)->getFilterSettings()->getName());
$this->assertNull(
$handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->getFilterSettings()->getName()
);
}
/**
* The bucket is what makes a list an all-season list, so every reader of the settings gets
* the scope without having to remember to apply it - the mailing above all.
*/
public function testTheScopeTravelsWithTheSettings(): void
{
$handler = $this->createHandler();
$this->assertFalse($handler->setScope(null)->getFilterSettings()->isAllSeason());
$this->assertTrue(
$handler->setScope(TeamerFilterHandler::SCOPE_ALL_SEASON)->getFilterSettings()->isAllSeason()
);
}
public function testAnUnknownScopeFallsBackToTheGeneralList(): void
{
$handler = $this->createHandler();
$this->save($handler->setScope(null), 'Fromme');
$this->assertSame('Fromme', $handler->setScope('nonsense')->getFilterSettings()->getName());
$this->assertFalse($handler->setScope('nonsense')->getFilterSettings()->isAllSeason());
}
private function save(TeamerFilterHandler $handler, string $name): void
{
$reflection = new \ReflectionMethod($handler, 'saveFilterSettings');
$reflection->invoke($handler, (new TeamerFilterDto())->setName($name));
}
private function createHandler(): TeamerFilterHandler
{
$request = new Request();
$request->setSession(new Session(new MockArraySessionStorage()));
$requestStack = new RequestStack();
$requestStack->push($request);
return new TeamerFilterHandler($requestStack, $this->createMock(EntityManagerInterface::class));
}
}
@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Cron;
use App\Config\EmailTextKey;
use App\Config\HouseCatalog;
use App\Config\SeasonCalendar;
use App\Config\SeasonTask;
use App\Email\Mailer;
use App\Entity\Embeddable\Communication;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Entity\Teamer;
use App\Entity\User;
use App\Repository\SeasonAssignmentTaskRepository;
use App\Repository\UserRepository;
use App\Service\Cron\SeasonTaskReminderService;
use App\Service\Season\SeasonAssignmentLabeler;
use App\Service\Season\SeasonTaskDeadlineResolver;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
/**
* Both rules pick out exactly one day, which is what lets the cron run twice without mailing
* anyone twice. That property, and what does and does not silence a reminder, is what is pinned
* here - the queries behind them are asserted in the repository test.
*/
class SeasonTaskReminderServiceTest extends TestCase
{
private SeasonAssignmentTaskRepository&MockObject $repository;
private UserRepository&MockObject $userRepository;
private Mailer&MockObject $mailer;
private SeasonTaskReminderService $service;
protected function setUp(): void
{
$this->repository = $this->createMock(SeasonAssignmentTaskRepository::class);
$this->userRepository = $this->createMock(UserRepository::class);
$this->mailer = $this->createMock(Mailer::class);
$this->service = new SeasonTaskReminderService(
$this->repository,
new SeasonTaskDeadlineResolver('-1 month'),
new SeasonAssignmentLabeler(
new SeasonCalendar('11-01', '04-01'),
new HouseCatalog(['AGR' => 'Rotbach'])
),
new SeasonCalendar('11-01', '04-01'),
$this->userRepository,
$this->mailer,
new NullLogger(),
);
}
public function testTheSummaryFiresOnTheDeadlineDay(): void
{
$this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]);
$this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]);
$this->mailer
->expects($this->once())
->method('createAndSendEmail')
->with(
$this->callback(static function (array $context): bool {
$entries = reset($context['violations']);
return 1 === count($entries) && 1 === $entries[0]['level'];
}),
$this->anything()
)
;
$this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01'));
}
public function testTheSummaryDoesNotFireTheDayAfter(): void
{
$this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]);
$this->mailer->expects($this->never())->method('createAndSendEmail');
$this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-02'));
}
/**
* Frist 2 defaults to Frist 1, so the two coincide unless the office moves one. The entry is
* then reported once and as Frist 1 - a second, identical line would be pure noise.
*/
public function testCoincidingDeadlinesAreReportedOnceAsTheFirst(): void
{
$this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]);
$this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]);
$levels = [];
$this->mailer
->method('createAndSendEmail')
->willReturnCallback(static function (array $context) use (&$levels): void {
foreach (reset($context['violations']) as $entry) {
$levels[] = $entry['level'];
}
})
;
$this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01'));
$this->assertSame([1], $levels);
}
public function testAPushedOutSecondDeadlineGetsItsOwnDay(): void
{
$task = $this->task('2026-11-01')->setDeadlineOverride2(new \DateTimeImmutable('2026-10-15'));
$this->repository->method('findOpen')->willReturn([$task]);
$this->userRepository->method('getUsersByRoles')->willReturn([$this->admin()]);
$levels = [];
$this->mailer
->method('createAndSendEmail')
->willReturnCallback(static function (array $context) use (&$levels): void {
foreach (reset($context['violations']) as $entry) {
$levels[] = $entry['level'];
}
})
;
$this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-15'));
$this->assertSame([2], $levels);
}
public function testAnAdminMutingNotificationsIsSkipped(): void
{
$this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]);
$this->userRepository->method('getUsersByRoles')->willReturn([
$this->admin()->setMuteNotifications(true),
]);
$this->mailer->expects($this->never())->method('createAndSendEmail');
$this->service->sendDeadlineSummary(new \DateTimeImmutable('2026-10-01'));
}
public function testTheDigestOnlyGoesOutOnMondays(): void
{
$this->repository->expects($this->never())->method('findOpen');
$this->mailer->expects($this->never())->method('createAndSendText');
// 2026-10-06 is a tuesday
$this->service->sendTeamerDigest(new \DateTimeImmutable('2026-10-06'));
}
public function testTheDigestListsTheOpenReturnEntries(): void
{
$this->repository->method('findOpen')->willReturn([$this->task('2026-11-01')]);
$this->mailer
->expects($this->once())
->method('createAndSendText')
->with(
EmailTextKey::REMINDER_SEASON_TASKS,
$this->callback(static fn (array $placeholders): bool => SeasonTask::CONTRACT->label() === $placeholders['tasks']),
$this->anything()
)
;
$this->service->sendTeamerDigest($this->monday());
}
public function testAnOfficeOnlyEntryNeverReachesTheTeamer(): void
{
$this->repository
->method('findOpen')
->willReturn([$this->task('2026-11-01', SeasonTask::SAAS_REGISTERED)])
;
$this->mailer->expects($this->never())->method('createAndSendText');
$this->service->sendTeamerDigest($this->monday());
}
/**
* The flag is the office's own note and silences nothing: only ticking the entry off does.
*/
public function testDoneTeamerDoesNotSuppressTheDigest(): void
{
$task = $this->task('2026-11-01');
$task->getSeasonAssignment()->setDoneTeamer(true);
$this->repository->method('findOpen')->willReturn([$task]);
$this->mailer->expects($this->once())->method('createAndSendText');
$this->service->sendTeamerDigest($this->monday());
}
private function monday(): \DateTimeImmutable
{
// 2026-10-05 is a monday, and inside the season the digest is scoped to
return new \DateTimeImmutable('2026-10-05');
}
private function task(string $dateFrom, SeasonTask $definition = SeasonTask::CONTRACT): SeasonAssignmentTask
{
$teamer = new Teamer();
$teamer->setCommunication((new Communication())->setEmail('[email protected]'));
$seasonAssignment = (new SeasonAssignment())
->setTeamer($teamer)
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months'))
->setHotelCode('AGR')
;
$task = (new SeasonAssignmentTask())->setTaskKey($definition->value);
$seasonAssignment->addTask($task);
return $task;
}
private function admin(): User
{
return (new User())->setEmail('[email protected]');
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Season;
use App\Config\HouseCatalog;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
use App\Service\Season\SeasonAssignmentLabeler;
use PHPUnit\Framework\TestCase;
/**
* Every list, dialog, log line and mail names a placement through this one class, so what it
* produces in the awkward cases - no dates yet, a date outside any season, a house that has left
* the configured map - is what the office would see there.
*/
class SeasonAssignmentLabelerTest extends TestCase
{
public function testItNamesTheSeasonAndTheHouse(): void
{
$this->assertSame(
'Saison 2026/27 - Rotbach',
$this->labeler()->label($this->seasonAssignment('2026-11-01', 'AGR'))
);
}
/**
* The season window is 01.11.-01.04., so a placement starting in July belongs to no season.
* It still has to name itself rather than render blank.
*/
public function testAPlacementOutsideAnySeasonSaysSo(): void
{
$this->assertSame(
'außerhalb der Saison - Rotbach',
$this->labeler()->label($this->seasonAssignment('2027-07-15', 'AGR'))
);
}
public function testAHalfFilledRecordStillNamesItself(): void
{
$this->assertSame('? - ?', $this->labeler()->label(new SeasonAssignment()));
}
/**
* The houses map is curated and can lose an entry; a placement that named that house falls
* back to the raw code rather than losing its name.
*/
public function testACodeThatLeftTheMapFallsBackToTheCode(): void
{
$this->assertSame(
'Saison 2026/27 - XYZ',
$this->labeler()->label($this->seasonAssignment('2026-11-01', 'XYZ'))
);
}
private function labeler(): SeasonAssignmentLabeler
{
return new SeasonAssignmentLabeler(
new SeasonCalendar('11-01', '04-01'),
new HouseCatalog(['AGR' => 'Rotbach'])
);
}
private function seasonAssignment(string $dateFrom, string $hotelCode): SeasonAssignment
{
return (new SeasonAssignment())
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months'))
->setHotelCode($hotelCode)
;
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Season;
use App\Config\SeasonTask;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonTaskDeadlineResolver;
use PHPUnit\Framework\TestCase;
/**
* Neither deadline is stored, so what is asserted here is the arithmetic every reminder, every
* checklist row and the teamer's own page read - a deadline computed one day off is as silent a
* failure as a reminder that never fires.
*/
class SeasonTaskDeadlineResolverTest extends TestCase
{
public function testTheFirstDeadlineIsTheOffsetAppliedToTheTravelStart(): void
{
$task = $this->task('2026-11-01');
$this->assertSame('2026-10-01', $this->resolver()->resolveFirst($task)?->format('Y-m-d'));
}
public function testTheOffsetComesFromTheParameter(): void
{
$task = $this->task('2026-11-01');
$this->assertSame(
'2026-10-18',
$this->resolver('-14 days')->resolveFirst($task)?->format('Y-m-d')
);
}
public function testAnOverriddenFirstDeadlineWins(): void
{
$task = $this->task('2026-11-01')->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15'));
$this->assertSame('2026-09-15', $this->resolver()->resolveFirst($task)?->format('Y-m-d'));
}
public function testTheSecondDeadlineDefaultsToTheFirst(): void
{
$task = $this->task('2026-11-01');
$this->assertSame('2026-10-01', $this->resolver()->resolveSecond($task)?->format('Y-m-d'));
}
public function testTheSecondDeadlineFollowsAnOverriddenFirstOne(): void
{
$task = $this->task('2026-11-01')->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15'));
$this->assertSame('2026-09-15', $this->resolver()->resolveSecond($task)?->format('Y-m-d'));
}
public function testAnOverriddenSecondDeadlineWinsOverBoth(): void
{
$task = $this
->task('2026-11-01')
->setDeadlineOverride1(new \DateTimeImmutable('2026-09-15'))
->setDeadlineOverride2(new \DateTimeImmutable('2026-10-20'))
;
$this->assertSame('2026-09-15', $this->resolver()->resolveFirst($task)?->format('Y-m-d'));
$this->assertSame('2026-10-20', $this->resolver()->resolveSecond($task)?->format('Y-m-d'));
}
public function testAnAssignmentWithoutAStartDateHasNoDeadlines(): void
{
$task = (new SeasonAssignmentTask())
->setTaskKey(SeasonTask::CONTRACT->value)
->setSeasonAssignment(new SeasonAssignment())
;
$this->assertNull($this->resolver()->resolveFirst($task));
$this->assertNull($this->resolver()->resolveSecond($task));
}
private function resolver(string $offset = '-1 month'): SeasonTaskDeadlineResolver
{
return new SeasonTaskDeadlineResolver($offset);
}
private function task(string $dateFrom): SeasonAssignmentTask
{
$seasonAssignment = (new SeasonAssignment())
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo(new \DateTimeImmutable($dateFrom.' +5 months'))
->setHotelCode('AGR')
;
$task = (new SeasonAssignmentTask())->setTaskKey(SeasonTask::CONTRACT->value);
$seasonAssignment->addTask($task);
return $task;
}
}
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Season;
use App\Config\SeasonTask;
use App\Entity\SeasonAssignment;
use App\Entity\SeasonAssignmentTask;
use App\Service\Season\SeasonTaskSynchronizer;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* What makes the enum extensible is that this fills in whatever an assignment is missing, so
* that is what is asserted: new entries appear on assignments that already exist, and rows of
* an entry that was removed are left alone rather than deleted.
*/
class SeasonTaskSynchronizerTest extends TestCase
{
private EntityManagerInterface&MockObject $entityManager;
private SeasonTaskSynchronizer $synchronizer;
protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->synchronizer = new SeasonTaskSynchronizer($this->entityManager);
}
public function testItCreatesOneRowPerEntryInEnumOrder(): void
{
$seasonAssignment = new SeasonAssignment();
$this->entityManager->expects($this->exactly(count(SeasonTask::cases())))->method('persist');
$this->entityManager->expects($this->once())->method('flush');
$this->synchronizer->sync($seasonAssignment);
$this->assertSame(
array_map(static fn (SeasonTask $task): string => $task->value, SeasonTask::cases()),
$this->keysOf($seasonAssignment)
);
}
public function testItOnlyCreatesWhatIsMissing(): void
{
$seasonAssignment = new SeasonAssignment();
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey(SeasonTask::CONTRACT->value));
$this->entityManager->expects($this->exactly(count(SeasonTask::cases()) - 1))->method('persist');
$this->synchronizer->sync($seasonAssignment);
$this->assertCount(count(SeasonTask::cases()), $seasonAssignment->getTasks());
$this->assertSame(
1,
count(array_filter(
$this->keysOf($seasonAssignment),
static fn (string $key): bool => SeasonTask::CONTRACT->value === $key
))
);
}
public function testASyncedAssignmentIsNotFlushedAgain(): void
{
$seasonAssignment = new SeasonAssignment();
foreach (SeasonTask::cases() as $case) {
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey($case->value));
}
$this->entityManager->expects($this->never())->method('persist');
$this->entityManager->expects($this->never())->method('flush');
$this->synchronizer->sync($seasonAssignment);
}
public function testARowOfAnUnknownEntryIsKept(): void
{
$seasonAssignment = new SeasonAssignment();
$seasonAssignment->addTask((new SeasonAssignmentTask())->setTaskKey('entry_removed_from_the_enum'));
$this->entityManager->expects($this->never())->method('remove');
$this->synchronizer->sync($seasonAssignment);
$this->assertCount(count(SeasonTask::cases()) + 1, $seasonAssignment->getTasks());
$this->assertContains('entry_removed_from_the_enum', $this->keysOf($seasonAssignment));
}
/**
* @return string[]
*/
private function keysOf(SeasonAssignment $seasonAssignment): array
{
return array_map(
static fn (SeasonAssignmentTask $task): string => $task->getTaskKey(),
$seasonAssignment->getTasks()->toArray()
);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Twig;
use App\Entity\Teamer;
use App\Entity\User;
use App\Twig\AppRuntime;
use PHPUnit\Framework\TestCase;
/**
* All-season is a flag on top of the status, not a status of its own: a teamer can be an
* all-season and an ordinary Bestandsteamer:in at the same time. The general Teamübersicht is
* the only place that overlap would otherwise be invisible, since both teamer lists render the
* same table and the status column never mentioned the flag.
*/
class TeamerStatusLabelTest extends TestCase
{
public function testAnAllSeasonTeamerKeepsTheirOrdinaryStatus(): void
{
$teamer = $this->createTeamer(Teamer::STATUS_EXISTING, true);
$this->assertSame('Bestandsteamer:in · Saison', $this->label($teamer));
}
public function testANewTeamerCanAlsoBeAllSeason(): void
{
$teamer = $this->createTeamer(Teamer::STATUS_NEW, true);
$this->assertSame('Neuteamer:in · Saison', $this->label($teamer));
}
public function testAnOrdinaryTeamerIsUnmarked(): void
{
$this->assertSame('Bestandsteamer:in', $this->label($this->createTeamer(Teamer::STATUS_EXISTING, false)));
$this->assertSame('Neuteamer:in', $this->label($this->createTeamer(Teamer::STATUS_NEW, false)));
}
/**
* A role replaces the status label entirely, but the flag is orthogonal to both - an admin
* who is also an all-season teamer has to stay recognisable as one.
*/
public function testARoleLabelStillCarriesTheMarker(): void
{
$teamer = $this->createTeamer(Teamer::STATUS_EXISTING, true);
$teamer->getUser()->setRoles(['ROLE_MANAGER']);
$this->assertSame('Reisemanagement · Saison', $this->label($teamer));
}
/**
* The dedicated Saisonteamer list is entirely all-season, so repeating it on every row
* would say nothing.
*/
public function testTheMarkerCanBeSuppressed(): void
{
$teamer = $this->createTeamer(Teamer::STATUS_EXISTING, true);
$this->assertSame('Bestandsteamer:in', $this->label($teamer, false));
}
private function label(Teamer $teamer, bool $includeAllSeason = true): string
{
return $this->createRuntime()->teamerStatusLabel($teamer, $includeAllSeason);
}
private function createTeamer(string $status, bool $allSeason): Teamer
{
$teamer = (new Teamer())
->setStatus($status)
->setAllSeason($allSeason)
;
// the label reads roles off the user, so every teamer needs one
(new User())->setTeamer($teamer);
return $teamer;
}
private function createRuntime(): AppRuntime
{
$reflection = new \ReflectionClass(AppRuntime::class);
return $reflection->newInstanceWithoutConstructor();
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Tests\Validator;
use App\Config\SeasonCalendar;
use App\Entity\SeasonAssignment;
use App\Entity\Teamer;
use App\Repository\SeasonAssignmentRepository;
use App\Validator\Constraints\SeasonAssignment as SeasonAssignmentConstraint;
use App\Validator\Constraints\SeasonAssignmentValidator;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
/**
* "One season assignment per teamer per season" was a stated fact before this rule; what is
* pinned here is which season window a given assignment is counted against, and the cases that
* must not be refused - an unstaffed assignment, and the assignment being edited itself.
*/
class SeasonAssignmentValidatorTest extends ConstraintValidatorTestCase
{
private SeasonAssignmentRepository&MockObject $repository;
protected function createValidator(): SeasonAssignmentValidator
{
$this->repository = $this->createMock(SeasonAssignmentRepository::class);
return new SeasonAssignmentValidator($this->repository, new SeasonCalendar('11-01', '04-01'));
}
public function testASecondAssignmentInTheSameSeasonIsRefused(): void
{
$this->repository->method('countInSeasonWindow')->willReturn(1);
$this->validator->validate($this->seasonAssignment('2026-12-20'), $this->constraint());
$this->buildViolation('Für diese:n Teamer:in existiert in dieser Saison bereits ein Saisoneinsatz')
->assertRaised()
;
}
public function testTheFirstAssignmentOfASeasonPasses(): void
{
$this->repository->method('countInSeasonWindow')->willReturn(0);
$this->validator->validate($this->seasonAssignment('2026-12-20'), $this->constraint());
$this->assertNoViolation();
}
public function testItCountsAgainstTheSeasonTheAssignmentStartsIn(): void
{
// 20.02.2027 belongs to the season that began on 01.11.2026, so the window asked for has
// to be that one and not the calendar year the date happens to fall in
$this->repository
->expects($this->once())
->method('countInSeasonWindow')
->with(
$this->isInstanceOf(Teamer::class),
$this->callback(static fn (\DateTimeImmutable $start): bool => '2026-11-01' === $start->format('Y-m-d')),
$this->callback(static fn (\DateTimeImmutable $end): bool => '2027-04-01' === $end->format('Y-m-d')),
null
)
->willReturn(0)
;
$this->validator->validate($this->seasonAssignment('2027-02-20'), $this->constraint());
$this->assertNoViolation();
}
public function testEditingAnAssignmentDoesNotCountItAgainstItself(): void
{
$seasonAssignment = $this->seasonAssignment('2026-12-20');
// the id is never set by hand in the app; it is what tells the query to leave the row
// being edited out, so an edit that changes nothing cannot refuse itself
$id = new \ReflectionProperty(SeasonAssignment::class, 'id');
$id->setValue($seasonAssignment, 42);
$this->repository
->expects($this->once())
->method('countInSeasonWindow')
->with($this->anything(), $this->anything(), $this->anything(), 42)
->willReturn(0)
;
$this->validator->validate($seasonAssignment, $this->constraint());
$this->assertNoViolation();
}
public function testAnAssignmentWithoutATeamerPasses(): void
{
$this->repository->expects($this->never())->method('countInSeasonWindow');
$seasonAssignment = $this->seasonAssignment('2026-12-20')->setTeamer(null);
$this->validator->validate($seasonAssignment, $this->constraint());
$this->assertNoViolation();
}
public function testAnAssignmentOutsideAnySeasonWindowPasses(): void
{
$this->repository->expects($this->never())->method('countInSeasonWindow');
$this->validator->validate($this->seasonAssignment('2027-07-15'), $this->constraint());
$this->assertNoViolation();
}
private function constraint(): SeasonAssignmentConstraint
{
return new SeasonAssignmentConstraint();
}
private function seasonAssignment(string $dateFrom): SeasonAssignment
{
return (new SeasonAssignment())
->setTeamer(new Teamer())
->setDateFrom(new \DateTimeImmutable($dateFrom))
->setDateTo(new \DateTimeImmutable($dateFrom.' +6 months'))
->setHotelCode('AGR')
;
}
}