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:
@@ -8,7 +8,9 @@ declare(strict_types=1);
|
||||
*/
|
||||
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\TimeTracking\TimeTrackingModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
TimeTrackingModule::class,
|
||||
];
|
||||
|
||||
@@ -34,6 +34,11 @@ doctrine:
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
TimeTracking:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
|
||||
prefix: 'App\Module\TimeTracking\Domain\Entity'
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
|
||||
@@ -73,4 +73,6 @@ services:
|
||||
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||
|
||||
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
|
||||
|
||||
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ return [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* TimeTracking module (2.1): add Timestampable/Blamable columns to time_entry.
|
||||
*
|
||||
* TimeEntry is the first adopter of TimestampableBlamableTrait. This migration
|
||||
* is purely additive — nullable columns + nullable FK to "user" with
|
||||
* ON DELETE SET NULL. No DROP/ALTER on existing data. Columns are lowercase
|
||||
* snake_case. Hand-written to guarantee zero destructive instruction.
|
||||
*/
|
||||
final class Version20260620161036 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'TimeTracking: add timestampable/blamable columns to time_entry (additive)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE time_entry ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE time_entry ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE time_entry ADD created_by INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE time_entry ADD updated_by INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0CDE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C16FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_6E537C0CDE12AB56 ON time_entry (created_by)');
|
||||
$this->addSql('CREATE INDEX IDX_6E537C0C16FE72E1 ON time_entry (updated_by)');
|
||||
$this->addSql("COMMENT ON COLUMN time_entry.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
|
||||
$this->addSql("COMMENT ON COLUMN time_entry.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
|
||||
$this->addSql("COMMENT ON COLUMN time_entry.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
|
||||
$this->addSql("COMMENT ON COLUMN time_entry.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0CDE12AB56');
|
||||
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C16FE72E1');
|
||||
$this->addSql('DROP INDEX IDX_6E537C0CDE12AB56');
|
||||
$this->addSql('DROP INDEX IDX_6E537C0C16FE72E1');
|
||||
$this->addSql('ALTER TABLE time_entry DROP created_at');
|
||||
$this->addSql('ALTER TABLE time_entry DROP updated_at');
|
||||
$this->addSql('ALTER TABLE time_entry DROP created_by');
|
||||
$this->addSql('ALTER TABLE time_entry DROP updated_by');
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ use App\Entity\TaskPriority;
|
||||
use App\Entity\TaskRecurrence;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Entity\Workflow;
|
||||
use App\Entity\ZimbraConfiguration;
|
||||
use App\Enum\AbsenceStatus;
|
||||
@@ -27,6 +26,7 @@ use App\Enum\RecurrenceType;
|
||||
use App\Enum\StatusCategory;
|
||||
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
|
||||
@@ -16,8 +16,8 @@ use App\Entity\TaskGroup;
|
||||
use App\Entity\TaskPriority;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
namespace App\Module\TimeTracking\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
@@ -13,9 +13,15 @@ use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Module\TimeTracking\Infrastructure\ApiPlatform\State\ActiveTimeEntryProvider;
|
||||
use App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\State\ActiveTimeEntryProvider;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -52,10 +58,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
|
||||
#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTimeEntryRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])]
|
||||
class TimeEntry
|
||||
class TimeEntry implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Domain\Repository;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface TimeEntryRepositoryInterface
|
||||
{
|
||||
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder;
|
||||
|
||||
public function findById(int $id): ?TimeEntry;
|
||||
|
||||
public function findActiveByUser(UserInterface $user): ?TimeEntry;
|
||||
|
||||
/**
|
||||
* @param null|UserInterface[] $users
|
||||
* @param null|Project[] $projects
|
||||
* @param null|int[] $tagIds
|
||||
*
|
||||
* @return TimeEntry[]
|
||||
*/
|
||||
public function findForExport(
|
||||
DateTimeImmutable $after,
|
||||
DateTimeImmutable $before,
|
||||
?array $users = null,
|
||||
?array $projects = null,
|
||||
?array $tagIds = null,
|
||||
): array;
|
||||
}
|
||||
+4
-4
@@ -2,12 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
namespace App\Module\TimeTracking\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
@@ -17,7 +17,7 @@ final readonly class ActiveTimeEntryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private TimeEntryRepository $timeEntryRepository,
|
||||
private TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
+4
-4
@@ -2,12 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Controller;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Service\TimeEntryExportService;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Module\TimeTracking\Infrastructure\Export\TimeEntryExportService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class TimeEntryExportController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly TimeEntryExportService $exportService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
+9
-3
@@ -2,10 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Doctrine;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
@@ -14,13 +15,18 @@ use Doctrine\Persistence\ManagerRegistry;
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TimeEntry>
|
||||
*/
|
||||
class TimeEntryRepository extends ServiceEntityRepository
|
||||
class DoctrineTimeEntryRepository extends ServiceEntityRepository implements TimeEntryRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TimeEntry::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?TimeEntry
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findActiveByUser(UserInterface $user): ?TimeEntry
|
||||
{
|
||||
return $this->findOneBy([
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Export;
|
||||
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
+4
-4
@@ -2,15 +2,15 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
@@ -29,7 +29,7 @@ class CreateTimeEntryTool
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
+4
-4
@@ -2,9 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
@@ -17,7 +17,7 @@ use function sprintf;
|
||||
class DeleteTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
@@ -28,7 +28,7 @@ class DeleteTimeEntryTool
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$entry = $this->timeEntryRepository->find($id);
|
||||
$entry = $this->timeEntryRepository->findById($id);
|
||||
|
||||
if (null === $entry) {
|
||||
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
||||
+3
-3
@@ -2,10 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
class ListTimeEntriesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
+4
-4
@@ -2,13 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
@@ -22,7 +22,7 @@ use function sprintf;
|
||||
class UpdateTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
@@ -47,7 +47,7 @@ class UpdateTimeEntryTool
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$entry = $this->timeEntryRepository->find($id);
|
||||
$entry = $this->timeEntryRepository->findById($id);
|
||||
|
||||
if (null === $entry) {
|
||||
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking;
|
||||
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
|
||||
final class TimeTrackingModule implements ModuleInterface
|
||||
{
|
||||
public static function id(): string
|
||||
{
|
||||
return 'time-tracking';
|
||||
}
|
||||
|
||||
public static function label(): string
|
||||
{
|
||||
return 'Suivi des temps';
|
||||
}
|
||||
|
||||
public static function isRequired(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions RBAC fin du Module TimeTracking (2.1).
|
||||
*
|
||||
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
|
||||
* reste en ROLE_USER (non recâblée ici).
|
||||
*
|
||||
* @return list<array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
|
||||
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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