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,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, '');
}
}