handler = new TestHandler(); $this->audit = new AuditLogger(new Logger('audit', [$this->handler])); } public function testACreatedBookingIsRecordedWithItsHolderAndResource(): void { $this->audit->bookingCreated($this->booking()); $record = $this->record(); self::assertSame('Booking created.', $record->message); self::assertSame('holder@ep-reisen.de', $record->context['holder']); self::assertSame('D1', $record->context['resource']); self::assertSame('2026-09-18', $record->context['date']); self::assertSame('09:00-13:00', $record->context['time']); } public function testARescheduledBookingKeepsTheSpanItCameFrom(): void { $booking = $this->booking(); $previous = $booking->getTimeRange(); $booking->changeTime(TimeRange::fromMinutes(600, 900)); $this->audit->bookingRescheduled($booking, $previous); $record = $this->record(); self::assertSame('Booking rescheduled.', $record->message); self::assertSame('09:00-13:00', $record->context['previous_time']); self::assertSame('10:00-15:00', $record->context['time']); } /** * A series is one record, not one per occurrence -- otherwise a year of weekly bookings * buries every other event in the file. */ public function testASeriesIsRecordedOnceWithTheDaysItCouldNotHave(): void { $series = new BookingSeries( $this->user(), $this->resource(), RecurrenceRule::WEEKLY, new \DateTimeImmutable('2026-09-18'), new \DateTimeImmutable('2026-10-02'), TimeRange::fromMinutes(540, 780), ); $this->audit->seriesCreated(new SeriesResult($series, [$this->booking()], ['2026-09-25' => 'taken'])); $records = $this->handler->getRecords(); self::assertCount(1, $records); self::assertSame('Booking series created.', $records[0]->message); self::assertSame(1, $records[0]->context['bookings_created']); self::assertSame(['2026-09-25'], $records[0]->context['skipped_dates']); } /** * Ending a series can delete it, and Doctrine nulls the id of a deleted entity -- so the id * is passed in, and the record must use the one it was given. */ public function testAnEndedSeriesIsRecordedWithTheIdItHadBeforeItWasDeleted(): void { $series = new BookingSeries( $this->user(), $this->resource(), RecurrenceRule::WEEKLY, new \DateTimeImmutable('2026-09-18'), new \DateTimeImmutable('2026-10-02'), TimeRange::fromMinutes(540, 780), ); $this->audit->seriesEnded($series, 42, new \DateTimeImmutable('2026-09-25'), 2); $record = $this->record(); self::assertSame(42, $record->context['series_id']); self::assertSame('2026-09-25', $record->context['ended_from']); self::assertSame(2, $record->context['bookings_removed']); } public function testRetiringAResourceIsRecordedAsTheTransitionItIs(): void { $resource = $this->resource(); $resource->setGroup(new ResourceGroup(ResourceType::DESK, 'Room 1')); $resource->setActive(false); $this->audit->resourceUpdated($resource, true); $record = $this->record(); self::assertSame('Resource updated.', $record->message); self::assertTrue($record->context['was_active']); self::assertFalse($record->context['active']); self::assertSame('Room 1', $record->context['group']); } public function testADeletedResourceIsRecordedWithHowManyBookingsWentWithIt(): void { $this->audit->resourceDeleted(7, 'D1', ResourceType::DESK, 3); $record = $this->record(); self::assertSame('Resource deleted.', $record->message); self::assertSame(7, $record->context['resource_id']); self::assertSame(3, $record->context['bookings_deleted']); } public function testASignInAndASignOutAreBothRecordedAgainstTheAccount(): void { $this->audit->signedIn($this->user()); $this->audit->signedOut($this->user()); $records = $this->handler->getRecords(); self::assertSame('User signed in.', $records[0]->message); self::assertSame('holder@ep-reisen.de', $records[0]->context['account']); self::assertSame('User signed out.', $records[1]->message); self::assertSame('holder@ep-reisen.de', $records[1]->context['account']); } /** * The one thing the trail must never carry. It is asserted rather than left to review, * because a helpful "log what changed" added later would leak every door code in the * building into a file that outlives the codes themselves. */ public function testAnAccessCodeIsRecordedWithoutItsValue(): void { $code = new AccessCode('Aufzug', '4711', new \DateTimeImmutable('2026-10-01')); $this->audit->accessCodeCreated($code); $this->audit->accessCodeUpdated($code, true, CodeVisibility::ADMIN); $this->audit->accessCodeDeleted(7, 'Aufzug', CodeVisibility::EVERYONE); foreach ($this->handler->getRecords() as $record) { self::assertStringNotContainsString('4711', json_encode($record->context, \JSON_THROW_ON_ERROR)); } $records = $this->handler->getRecords(); self::assertSame('Aufzug', $records[0]->context['access_code']); self::assertSame('2026-10-01', $records[0]->context['valid_from']); self::assertSame('everyone', $records[0]->context['visibility']); self::assertTrue($records[1]->context['code_changed']); self::assertSame('admin', $records[1]->context['previous_visibility']); self::assertSame(7, $records[2]->context['access_code_id']); } private function record(): LogRecord { return $this->handler->getRecords()[0]; } private function booking(): Booking { return new Booking( $this->user(), $this->resource(), new \DateTimeImmutable('2026-09-18'), TimeRange::fromMinutes(540, 780), ); } private function user(): User { return new User('holder@ep-reisen.de', 'Holder'); } private function resource(): Resource { return new Resource(ResourceType::DESK, 'D1'); } }