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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user