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

@@ -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()
);
}
}