feat(project-management) : add timestampable/blamable to Task and Project (additive)

Tranche 3 of LST-65. Task and Project adopt TimestampableBlamableTrait.

- Additive migration on task and project: created_at/updated_at (nullable),
  created_by/updated_by (nullable INT, FK to "user" ON DELETE SET NULL) +
  indexes + COMMENT ON COLUMN. down() drops only the added objects.
- Trait fields stay out of the existing API groups (trait carries its own).
- Functional test (TaskTimestampableTest) confirms created_at on persist and
  updated_at refresh on update.

161 tests green, no destructive migration.
This commit is contained in:
Matthieu
2026-06-20 17:05:47 +02:00
parent 23809f165e
commit c90d91d6c4
4 changed files with 193 additions and 2 deletions
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Task adopts TimestampableBlamableTrait (LST-65 slice 3).
* Verifies the TimestampableBlamableSubscriber populates created_at on
* persist and refreshes updated_at on update.
*
* @internal
*/
final class TaskTimestampableTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->executeStatement("DELETE FROM task WHERE title = 'ts-test-task'");
}
protected function tearDown(): void
{
$this->em->getConnection()->executeStatement("DELETE FROM task WHERE title = 'ts-test-task'");
parent::tearDown();
unset($this->em);
}
public function testCreatedAtIsSetOnPersist(): void
{
$task = $this->makeTask();
$this->em->persist($task);
$this->em->flush();
self::assertInstanceOf(DateTimeImmutable::class, $task->getCreatedAt(), 'createdAt must be set after flush');
self::assertInstanceOf(DateTimeImmutable::class, $task->getUpdatedAt(), 'updatedAt must be set after flush');
$taskId = $task->getId();
$this->em->clear();
$reloaded = $this->em->getRepository(Task::class)->find($taskId);
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCreatedAt(), 'created_at must be persisted in DB');
}
public function testUpdatedAtIsRefreshedOnUpdate(): void
{
$task = $this->makeTask();
$this->em->persist($task);
$this->em->flush();
$taskId = $task->getId();
$initialUpdatedAt = $task->getUpdatedAt();
self::assertNotNull($initialUpdatedAt);
// Backdate the persisted value so the preUpdate refresh is observable
// even within the same second.
$this->em->getConnection()->executeStatement(
"UPDATE task SET updated_at = '2000-01-01 00:00:00' WHERE id = :id",
['id' => $taskId],
);
$this->em->clear();
/** @var Task $managed */
$managed = $this->em->getRepository(Task::class)->find($taskId);
self::assertSame('2000-01-01 00:00:00', $managed->getUpdatedAt()?->format('Y-m-d H:i:s'));
$managed->setDescription('changed description');
$this->em->flush();
$this->em->clear();
/** @var Task $reloaded */
$reloaded = $this->em->getRepository(Task::class)->find($taskId);
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 makeTask(): Task
{
$project = $this->em->getRepository(Project::class)->findOneBy([]);
self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.');
$task = new Task();
$task->setNumber(random_int(100000, 999999));
$task->setTitle('ts-test-task');
$task->setProject($project);
return $task;
}
}