feat(time-tracking) : migrate TimeEntry into TimeTracking module (back)

First business module of Phase 2 (LST-64, rodage). Strangler-style,
additive move — no behavioural change to the public API or MCP tools.

- New module App\Module\TimeTracking (TimeTrackingModule, id "time-tracking",
  declares time-tracking.entries.view/export permissions in the RBAC catalog;
  operation security left on ROLE_USER, not re-wired here).
- Move TimeEntry entity, repository (now interface + Doctrine impl bound in
  services.yaml), ActiveTimeEntryProvider, export service/controller and the
  4 MCP TimeEntry tools into the module. #[ApiResource] (operations, security,
  uriTemplates /time_entries/*), filters and serialization groups preserved.
- Doctrine mapping "TimeTracking" added; table time_entry unchanged.
- Sidebar item gated with module "time-tracking" (SidebarFilter disables the
  route when the module is inactive).
- Timestampable/Blamable adopted (first adopter): additive migration adds
  created_at/updated_at/created_by/updated_by (nullable, FK SET NULL) +
  COMMENT ON COLUMN. Functional test confirms created_at on persist and
  updated_at refresh on update — the suspected preUpdate recompute issue does
  not occur (Doctrine ORM 3.6.2 recomputes change sets after preUpdate).

159 tests green, schema mapping valid, php-cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 16:16:13 +02:00
parent a88cb1bc35
commit d1516c3f5d
19 changed files with 298 additions and 36 deletions
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\TimeTracking;
use App\Module\Core\Domain\Entity\User;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* TimeEntry is the first adopter of TimestampableBlamableTrait.
* Verifies the TimestampableBlamableSubscriber populates created_at on
* persist and refreshes updated_at on update.
*
* @internal
*/
final class TimeEntryTimestampableTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
// Clean slate for deterministic assertions: these tests are not wrapped
// in a rolled-back transaction.
$this->em->getConnection()->executeStatement("DELETE FROM time_entry WHERE title = 'ts-test-entry'");
$this->em->getConnection()->executeStatement("DELETE FROM \"user\" WHERE username = 'ts_test_user'");
}
protected function tearDown(): void
{
$this->em->getConnection()->executeStatement("DELETE FROM time_entry WHERE title = 'ts-test-entry'");
$this->em->getConnection()->executeStatement("DELETE FROM \"user\" WHERE username = 'ts_test_user'");
parent::tearDown();
unset($this->em);
}
public function testCreatedAtIsSetOnPersist(): void
{
$entry = $this->makeEntry();
$this->em->persist($entry);
$this->em->flush();
self::assertInstanceOf(DateTimeImmutable::class, $entry->getCreatedAt(), 'createdAt must be set after flush');
self::assertInstanceOf(DateTimeImmutable::class, $entry->getUpdatedAt(), 'updatedAt must be set after flush');
// Confirm it was actually persisted to the DB, not just the in-memory object.
$this->em->clear();
$reloaded = $this->em->getRepository(TimeEntry::class)->find($entry->getId());
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCreatedAt(), 'created_at must be persisted in DB');
}
public function testUpdatedAtIsRefreshedOnUpdate(): void
{
$entry = $this->makeEntry();
$this->em->persist($entry);
$this->em->flush();
$entryId = $entry->getId();
$initialUpdatedAt = $entry->getUpdatedAt();
self::assertNotNull($initialUpdatedAt);
// Force a distinguishable timestamp by backdating the persisted value,
// so the preUpdate refresh is observable even within the same second.
$this->em->getConnection()->executeStatement(
"UPDATE time_entry SET updated_at = '2000-01-01 00:00:00' WHERE id = :id",
['id' => $entryId],
);
$this->em->clear();
/** @var TimeEntry $managed */
$managed = $this->em->getRepository(TimeEntry::class)->find($entryId);
self::assertSame('2000-01-01 00:00:00', $managed->getUpdatedAt()?->format('Y-m-d H:i:s'));
// Mutate a tracked field and flush -> preUpdate must refresh updated_at.
$managed->setTitle('ts-test-entry');
$managed->setDescription('changed description');
$this->em->flush();
$this->em->clear();
/** @var TimeEntry $reloaded */
$reloaded = $this->em->getRepository(TimeEntry::class)->find($entryId);
self::assertNotNull($reloaded->getUpdatedAt());
self::assertNotSame(
'2000-01-01 00:00:00',
$reloaded->getUpdatedAt()->format('Y-m-d H:i:s'),
'updated_at must be refreshed and persisted on UPDATE',
);
}
private function makeEntry(): TimeEntry
{
$user = new User();
$user->setUsername('ts_test_user');
$user->setPassword('hashed-secret');
$user->setRoles(['ROLE_USER']);
$this->em->persist($user);
$entry = new TimeEntry();
$entry->setUser($user);
$entry->setTitle('ts-test-entry');
$entry->setStartedAt(new DateTimeImmutable());
return $entry;
}
}