feat: all-season assignments
This commit is contained in:
1 parent
675cec1b69
commit
cf876ed224
119 files changed
+6194
-294
No files matched your search
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user