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 DatabaseRequired('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 */ 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 $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); } }