feat: collect assignment and disposition events for statistics

This commit is contained in:
2026-08-26 15:17:17 +02:00
parent e210021fe3
commit 2662d56ac1
26 changed files with 2557 additions and 96 deletions
@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Disposition;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Enum\CallOffScope;
use App\Enum\StatisticsEventName;
use App\EventListener\StatisticsChangeSetListener;
use App\Service\Statistics\Collector\ApplicationCreatedCollector;
use App\Service\Statistics\Collector\CallOffCollector;
use App\Service\Statistics\Collector\JobProfileChangeCollector;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
/**
* These two metrics exist because the data behind them is otherwise unrecoverable - a job
* profile change overwrites its own history - so the thing worth pinning is *when* a row
* gets written and when it must not.
*/
class StatisticsChangeSetListenerTest extends TestCase
{
private StatisticsRecorder&MockObject $recorder;
protected function setUp(): void
{
$this->recorder = $this->createMock(StatisticsRecorder::class);
}
public function testRecordsBothSidesOfAJobProfileChange(): void
{
$assignment = new Assignment();
$old = (new JobProfile())->setName('Skilehrer');
$new = (new JobProfile())->setName('Hausleitung');
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$assignment,
$this->callback(static function (array $payload): bool {
return 'Skilehrer' === $payload['from']['name']
&& 'Hausleitung' === $payload['to']['name'];
})
)
;
$this->flush([[$assignment, ['jobProfile' => [$old, $new]]]]);
}
/**
* An assignment gets edited constantly. Only the job profile column may produce a row.
*/
public function testIgnoresAnAssignmentEditThatLeavesTheJobProfileAlone(): void
{
$this->recorder->expects($this->never())->method('recordForAssignment');
$this->flush([[new Assignment(), ['remarks' => ['alt', 'neu'], 'pickup' => [1, 2]]]]);
}
/**
* A newly created assignment gets its profile as an insert, not an update, and is not a
* change anybody made.
*/
public function testIgnoresAJobProfileThatDidNotActuallyChange(): void
{
$profile = (new JobProfile())->setName('Skilehrer');
$this->recorder->expects($this->never())->method('recordForAssignment');
$this->flush([[new Assignment(), ['jobProfile' => [$profile, $profile]]]]);
}
public function testRecordsACallOffWithWhoseDecisionItWas(): void
{
$disposition = $this->disposition();
$disposition
->setCalledOffBy(Disposition::CALLED_OFF_BY_TEAMER)
->setCalledOffReason('Krankheit')
;
$this->recorder
->expects($this->once())
->method('recordForDisposition')
->with(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$disposition,
$this->callback(static function (array $payload): bool {
return Disposition::CALLED_OFF_BY_TEAMER === $payload['called_off_by']
&& 'Krankheit' === $payload['reason']
&& Disposition::STATUS_CONFIRMED === $payload['previous_status'];
})
)
;
$this->flush([[$disposition, ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]]]);
}
public function testIgnoresAnyOtherDispositionStatusChange(): void
{
$this->recorder->expects($this->never())->method('recordForDisposition');
$this->flush([[$this->disposition(), [
'status' => [Disposition::STATUS_NEW, Disposition::STATUS_CONFIRMED],
]]]);
}
/**
* Calling off a whole assignment cascades the status onto every disposition in one
* flush, which is exactly why this listens on the changeset instead of on
* DispositionCalledOffEvent - that event is never dispatched for the cascade.
*/
public function testRecordsEveryDispositionOfACascadedAssignmentCallOff(): void
{
$assignment = new Assignment();
$this->recorder->expects($this->exactly(2))->method('recordForDisposition');
$this->flush([
[$assignment, ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
]);
}
/**
* One office decision that cancels a trip with two teamers must not read as two office
* decisions. The scope is what separates the decision from its cost.
*/
public function testACascadedDispositionIsScopedToTheAssignment(): void
{
$assignment = new Assignment();
$this->recorder
->expects($this->exactly(2))
->method('recordForDisposition')
->with(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$this->anything(),
$this->callback(static fn (array $payload): bool => CallOffScope::ASSIGNMENT->value === $payload['scope'])
)
;
$this->flush([
[$assignment, ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
]);
}
/**
* A teamer dropping out leaves the trip and everyone else on it running.
*/
public function testALoneCallOffIsScopedToTheDispositionOnly(): void
{
$this->recorder
->expects($this->once())
->method('recordForDisposition')
->with(
StatisticsEventName::DISPOSITION_CALLED_OFF,
$this->anything(),
$this->callback(static fn (array $payload): bool => CallOffScope::DISPOSITION->value === $payload['scope'])
)
;
$this->recorder->expects($this->never())->method('recordForAssignment');
$this->flush([[$this->disposition(), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]]]);
}
/**
* The trip-level decision is its own row, carrying what the cancellation cost so that
* "how many trips did we cancel" does not have to be counted back out of the
* dispositions.
*/
public function testTheTripCancellationIsRecordedOnceWithItsCost(): void
{
$assignment = new Assignment();
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
$assignment,
$this->callback(static fn (array $payload): bool => 2 === $payload['dispositions_affected'])
)
;
$this->flush([
[$assignment, ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
[$this->disposition($assignment), ['status' => [Disposition::STATUS_CONFIRMED, Disposition::STATUS_CALLED_OFF]]],
]);
}
/**
* A trip cancelled before anyone was staffed still counts as a decision - 58 of the 88
* in the database are exactly that.
*/
public function testAnUnstaffedTripCancellationCostsNobody(): void
{
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(
StatisticsEventName::ASSIGNMENT_CALLED_OFF,
$this->anything(),
$this->callback(static fn (array $payload): bool => 0 === $payload['dispositions_affected'])
)
;
$this->flush([[new Assignment(), ['status' => [Assignment::STATUS_PUBLISHED, Assignment::STATUS_CALLED_OFF]]]]);
}
/**
* A second flush in the same request must not replay what the first already recorded.
*/
public function testDoesNotRecordTheSameChangeTwice(): void
{
$assignment = new Assignment();
$old = (new JobProfile())->setName('Skilehrer');
$new = (new JobProfile())->setName('Hausleitung');
$this->recorder->expects($this->once())->method('recordForAssignment');
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs([[$assignment, ['jobProfile' => [$old, $new]]]]));
$listener->postFlush($this->postFlushArgs());
$listener->postFlush($this->postFlushArgs());
}
/**
* A flush that throws never reaches postFlush. What it collected has to die with it,
* rather than be written by whichever flush succeeds next - in a worker that is a
* different message, a different actor and a later date.
*/
public function testAFailedFlushDoesNotLeakIntoTheNextOne(): void
{
$survivor = new Assignment();
$this->recorder
->expects($this->once())
->method('recordForAssignment')
->with(StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED, $survivor, $this->anything())
;
$listener = $this->listener();
// Collected, and then the commit blows up: no postFlush for this one.
$listener->onFlush($this->onFlushArgs([[new Assignment(), [
'jobProfile' => [$this->jobProfile('Skilehrer'), $this->jobProfile('Hausleitung')],
]]]));
$listener->onFlush($this->onFlushArgs([[$survivor, [
'jobProfile' => [$this->jobProfile('Hausleitung'), $this->jobProfile('Skilehrer')],
]]]));
$listener->postFlush($this->postFlushArgs());
}
/**
* Workers reset their services between messages, which is the other way a failed
* flush's leftovers get dropped rather than written by an unrelated one.
*/
public function testResettingDropsWhatAFailedFlushCollected(): void
{
$this->recorder->expects($this->never())->method('recordForAssignment');
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs([[new Assignment(), [
'jobProfile' => [$this->jobProfile('Skilehrer'), $this->jobProfile('Hausleitung')],
]]]));
$listener->reset();
$listener->postFlush($this->postFlushArgs());
}
/**
* Folded in from a domain-event subscriber: reading the insertions catches every path
* that persists an application, not only the ones that remember to dispatch.
*/
public function testAnApplicationIsRecordedWhenItIsPersisted(): void
{
$application = new Application(new Assignment(), new Teamer());
$this->recorder
->expects($this->once())
->method('recordForApplication')
->with(StatisticsEventName::APPLICATION_CREATED, $application, [])
;
$this->flush([], [$application]);
}
/**
* Editing an application later is not a second application.
*/
public function testAnUpdatedApplicationIsNotRecordedAgain(): void
{
$this->recorder->expects($this->never())->method('recordForApplication');
$this->flush([[new Application(new Assignment(), new Teamer()), ['remarks' => ['a', 'b']]]]);
}
/**
* Other entities are inserted constantly; only applications count here.
*/
public function testOtherInsertionsAreIgnored(): void
{
$this->recorder->expects($this->never())->method('recordForApplication');
$this->flush([], [new Assignment(), new Teamer()]);
}
/**
* @param array<int, array{0: object, 1: array<string, mixed>}> $changeSets
* @param array<int, object> $insertions
*/
private function flush(array $changeSets, array $insertions = []): void
{
$listener = $this->listener();
$listener->onFlush($this->onFlushArgs($changeSets, $insertions));
$listener->postFlush($this->postFlushArgs());
}
/**
* @param array<int, array{0: object, 1: array<string, mixed>}> $changeSets
* @param array<int, object> $insertions
*/
private function onFlushArgs(array $changeSets, array $insertions = []): OnFlushEventArgs
{
$entities = [];
$byEntity = new \SplObjectStorage();
foreach ($changeSets as [$entity, $changeSet]) {
$entities[] = $entity;
$byEntity[$entity] = $changeSet;
}
$unitOfWork = $this->createMock(UnitOfWork::class);
$unitOfWork->method('getScheduledEntityUpdates')->willReturn($entities);
$unitOfWork->method('getScheduledEntityInsertions')->willReturn($insertions);
$unitOfWork
->method('getEntityChangeSet')
->willReturnCallback(static fn (object $entity): array => $byEntity[$entity] ?? [])
;
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->method('getUnitOfWork')->willReturn($unitOfWork);
return new OnFlushEventArgs($entityManager);
}
private function postFlushArgs(): PostFlushEventArgs
{
return new PostFlushEventArgs($this->createMock(EntityManagerInterface::class));
}
/**
* Built with the real collectors, so these tests cover them as well as the timing.
*/
private function listener(): StatisticsChangeSetListener
{
return new StatisticsChangeSetListener($this->recorder, [
new JobProfileChangeCollector(),
new CallOffCollector(),
new ApplicationCreatedCollector(),
]);
}
private function jobProfile(string $name): JobProfile
{
return (new JobProfile())->setName($name);
}
private function disposition(?Assignment $assignment = null): Disposition
{
return new Disposition(new Application($assignment ?? new Assignment(), new Teamer()));
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\Assignment;
use App\Form\AssignmentType;
use PHPUnit\Framework\TestCase;
/**
* Calling off an assignment has to go through the dedicated action, because that is the
* only path that also cancels the dispositions on it. Offering "abgesagt" in this form left
* teamers holding a live placement on a trip that had been cancelled - 23 assignments in the
* database are in exactly that state.
*
* The choice list is asserted directly rather than through a built form: the form also
* carries EntityType fields that query the database, and this suite does not have one.
* ChoiceType validates submissions against exactly this list, so it is the enforcement
* point, not merely what gets rendered.
*/
class AssignmentTypeTest extends TestCase
{
public function testADraftCannotBeCalledOffThroughTheStatusField(): void
{
$this->assertSame(
[Assignment::STATUS_DRAFT, Assignment::STATUS_PUBLISHED],
$this->statusChoices(Assignment::STATUS_DRAFT)
);
}
/**
* The published case is the one that mattered: a staffed trip cancelled from the edit
* form kept every disposition on it alive.
*/
public function testAPublishedAssignmentCannotBeCalledOffEither(): void
{
$this->assertNotContains(Assignment::STATUS_CALLED_OFF, $this->statusChoices(Assignment::STATUS_PUBLISHED));
}
/**
* Otherwise opening its edit form would render an empty status, and saving anything
* else on the page would silently resurrect the assignment.
*/
public function testAnAlreadyCalledOffAssignmentKeepsTheValue(): void
{
$this->assertContains(Assignment::STATUS_CALLED_OFF, $this->statusChoices(Assignment::STATUS_CALLED_OFF));
}
/**
* There is no dedicated undo for a call-off, so this form stays the way back.
*/
public function testACalledOffAssignmentCanStillBeRestored(): void
{
$this->assertContains(Assignment::STATUS_PUBLISHED, $this->statusChoices(Assignment::STATUS_CALLED_OFF));
}
/**
* A brand new assignment has no entity behind the form yet.
*/
public function testTheChoicesHoldWithoutAnAssignment(): void
{
$this->assertNotContains(Assignment::STATUS_CALLED_OFF, array_values(AssignmentType::statusChoices(null)));
}
/**
* @return array<int, string>
*/
private function statusChoices(string $status): array
{
return array_values(AssignmentType::statusChoices((new Assignment())->setStatus($status)));
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\StatisticsEvent;
use App\Enum\StatisticsDateBasis;
use App\Enum\StatisticsEventName;
use App\Repository\StatisticsEventRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Everywhere else in this app a date range means the season an assignment runs in, and
* these events are recorded months before it - an application for the coming winter
* arrives in summer. Picking the wrong basis does not fail, it quietly answers a
* different question from the screen next to it, so the compiled DQL is pinned here.
*/
class StatisticsEventRepositoryTest extends KernelTestCase
{
public function testAPeriodMeansTheSeasonUnlessToldOtherwise(): void
{
$dql = $this->query(dateFrom: new \DateTimeImmutable('2026-07-01'))->getDQL();
$this->assertStringContainsString('assignment.dateFrom >= :dateFrom', $dql);
$this->assertStringNotContainsString('event.occurredAt', $dql);
}
/**
* The season start is assignment.dateFrom falling back to the destination's, per
* Assignment::getEffectivePeriod(). Losing the fallback would silently drop every
* assignment that inherits its dates.
*/
public function testTheSeasonFallsBackToTheDestinationDates(): void
{
$dql = $this->query(
dateFrom: new \DateTimeImmutable('2026-07-01'),
dateTo: new \DateTimeImmutable('2027-06-30'),
)->getDQL();
$this->assertStringContainsString('assignment.dateFrom IS NULL', $dql);
$this->assertStringContainsString('destination.dateFrom >= :dateFrom', $dql);
$this->assertStringContainsString('assignment.dateTo IS NULL', $dql);
$this->assertStringContainsString('destination.dateTo <= :dateTo', $dql);
}
/**
* The dimension columns are plain integers, not relations, so the assignment is only
* reachable through an arbitrary join.
*/
public function testTheSeasonReachesTheAssignmentThroughAnArbitraryJoin(): void
{
$dql = $this->query(dateFrom: new \DateTimeImmutable('2026-07-01'))->getDQL();
$this->assertStringContainsString('assignment.id = event.assignmentId', $dql);
}
public function testOccurrenceAsksWhenTheEventWasRecordedAndJoinsNothing(): void
{
$dql = $this->query(
dateFrom: new \DateTimeImmutable('2026-07-01'),
dateBasis: StatisticsDateBasis::OCCURRENCE,
)->getDQL();
$this->assertStringContainsString('event.occurredAt >= :dateFrom', $dql);
$this->assertStringNotContainsString('JOIN', $dql);
}
/**
* An unfiltered count spans every season there is, so there is nothing to join for.
*/
public function testNoPeriodMeansNoJoinEitherWay(): void
{
$this->assertStringNotContainsString('JOIN', $this->query()->getDQL());
}
public function testTheGroupingColumnStaysWhitelisted(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->query(dimension: 'payload');
}
private function query(
string $dimension = 'hotelCode',
?\DateTimeImmutable $dateFrom = null,
?\DateTimeImmutable $dateTo = null,
StatisticsDateBasis $dateBasis = StatisticsDateBasis::SEASON,
): Query {
return $this->repository()->getCountGroupedByQuery(
StatisticsEventName::APPLICATION_CREATED,
$dimension,
$dateFrom,
$dateTo,
$dateBasis,
);
}
private function repository(): StatisticsEventRepository
{
/** @var EntityManagerInterface $entityManager */
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
/** @var StatisticsEventRepository $repository */
$repository = $entityManager->getRepository(StatisticsEvent::class);
return $repository;
}
}
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Statistics;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\User;
use App\Enum\StatisticsActorRole;
use App\Enum\StatisticsEventName;
use App\Service\Statistics\StatisticsRecorder;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception as DbalException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
class StatisticsRecorderTest extends TestCase
{
private Connection&MockObject $connection;
private Security&MockObject $security;
private LoggerInterface&MockObject $logger;
protected function setUp(): void
{
$this->connection = $this->createMock(Connection::class);
$this->security = $this->createMock(Security::class);
$this->logger = $this->createMock(LoggerInterface::class);
}
public function testWritesTheEnumValueAndLeavesUnusedDimensionsNull(): void
{
$written = $this->capture();
$this->recorder()->record(StatisticsEventName::APPLICATION_CREATED, ['application_id' => 7]);
$this->assertSame('application.created', $written['data']['name']);
$this->assertSame('statistics_event', $written['table']);
$this->assertSame(7, $written['data']['application_id']);
$this->assertNull($written['data']['disposition_id']);
$this->assertNull($written['data']['hotel_code']);
}
/**
* The hotel metric is asked for by hotel, not by destination, and several destinations
* share one hotel under a SER prefix.
*/
public function testFreezesTheNormalizedHotelCodeOfTheAssignment(): void
{
$written = $this->capture();
$this->recorder()->recordForAssignment(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$this->assignmentInHotel('SERZIL'),
);
$this->assertSame('ZIL', $written['data']['hotel_code']);
}
public function testKeepsAnUnprefixedHotelCodeAsItIs(): void
{
$written = $this->capture();
$this->recorder()->recordForAssignment(
StatisticsEventName::ASSIGNMENT_JOB_PROFILE_CHANGED,
$this->assignmentInHotel('SBW'),
);
$this->assertSame('SBW', $written['data']['hotel_code']);
}
public function testAttributesAnAdministrativeUser(): void
{
$this->givenUser(['ROLE_ADMINISTRATIVE']);
$this->assertSame(StatisticsActorRole::ADMIN, $this->recorder()->resolveActorRole());
}
public function testAttributesATeamer(): void
{
$this->givenUser(['ROLE_TEAMER']);
$this->assertSame(StatisticsActorRole::TEAMER, $this->recorder()->resolveActorRole());
}
/**
* The backfill and the cron run without a token.
*/
public function testAttributesAnythingWithoutATokenToTheSystem(): void
{
$this->security->method('getUser')->willReturn(null);
$this->assertSame(StatisticsActorRole::SYSTEM, $this->recorder()->resolveActorRole());
}
/**
* Statistics are a side effect. Losing one must never cost the teamer their
* application or the admin their edit.
*/
public function testSwallowsAWriteFailureInsteadOfBreakingTheAction(): void
{
$this->connection
->method('insert')
->willThrowException(new DbalException('no such table'))
;
$this->logger->expects($this->once())->method('error');
$this->recorder()->record(StatisticsEventName::APPLICATION_CREATED);
}
/**
* A payload that will not encode is a lost event either way. What must not happen is
* losing it to a DBAL error about the column, with nothing in the log pointing at the
* payload that caused it.
*/
public function testReportsAPayloadThatCannotBeEncodedInsteadOfWritingGarbage(): void
{
$this->connection->expects($this->never())->method('insert');
$this->logger->expects($this->once())->method('error');
$this->recorder()->record(
StatisticsEventName::DISPOSITION_CALLED_OFF,
[],
['reason' => "\xB1\x31"],
);
}
/**
* Hands back the row the recorder writes. An ArrayObject rather than an array, so the
* callback can fill it in after this method has already returned.
*
* @return \ArrayObject<string, mixed>
*/
private function capture(): \ArrayObject
{
$written = new \ArrayObject(['table' => '', 'data' => []]);
$this->connection
->method('insert')
->willReturnCallback(static function (string $table, array $data) use ($written): int {
$written['table'] = $table;
$written['data'] = $data;
return 1;
})
;
return $written;
}
private function recorder(): StatisticsRecorder
{
return new StatisticsRecorder($this->connection, $this->security, $this->logger);
}
/**
* @param array<int, string> $grantedRoles
*/
private function givenUser(array $grantedRoles): void
{
$this->security->method('getUser')->willReturn(new User());
$this->security
->method('isGranted')
->willReturnCallback(static fn (mixed $role): bool => in_array($role, $grantedRoles, true))
;
}
private function assignmentInHotel(string $hotelCode): Assignment
{
$destination = (new Destination())->setHotelCode($hotelCode);
return (new Assignment())->setDestination($destination);
}
}