feat: collect assignment and disposition events for statistics
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user