From b989c33cc4dfeb053025feb916fb769cde61eaa5 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 15 Jun 2026 16:08:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(shared)=20:=20infra=20upload=20g=C3=A9n?= =?UTF-8?q?=C3=A9rique=20(ERP-154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pose une infra d'upload de fichiers générique et réutilisable dans Shared (spec M4 § 2.7), sans toucher au module Transport. - Table uploaded_document (migration racine DoctrineMigrations) : fichier téléversé immuable (PDF / images), checksum sha256, created_at/created_by. - Service Shared\Infrastructure\Upload\FileUploader : validation MIME server-side via getMimeType (jamais getClientMimeType), whitelist explicite (PDF + images), bornage taille, checksum sha256, écriture var/uploads/{yyyy}/{mm}/. - Endpoint POST /api/uploaded_documents (multipart, deserialize:false) + UploadedDocumentProcessor -> renvoie l'IRI ; MIME hors whitelist -> 422. - COMMENT ON COLUMN sur toutes les colonnes + bloc dans ColumnCommentsCatalog. - Mapping Doctrine Shared + path API Platform Shared. - Tests : FileUploader (unit) + endpoint (fonctionnel, 422 / IRI / checksum). --- config/packages/api_platform.yaml | 3 + config/packages/doctrine.yaml | 10 ++ migrations/Version20260615130000.php | 86 ++++++++++ src/Shared/Domain/Entity/UploadedDocument.php | 160 ++++++++++++++++++ .../Exception/FileTooLargeException.php | 21 +++ .../Domain/Exception/FileUploadException.php | 16 ++ .../UnsupportedMimeTypeException.php | 24 +++ .../State/UploadedDocumentProcessor.php | 68 ++++++++ .../Database/ColumnCommentsCatalog.php | 12 ++ .../Infrastructure/Upload/FileUploader.php | 107 ++++++++++++ tests/Shared/Api/UploadedDocumentApiTest.php | 132 +++++++++++++++ .../Upload/FileUploaderTest.php | 144 ++++++++++++++++ 12 files changed, 783 insertions(+) create mode 100644 migrations/Version20260615130000.php create mode 100644 src/Shared/Domain/Entity/UploadedDocument.php create mode 100644 src/Shared/Domain/Exception/FileTooLargeException.php create mode 100644 src/Shared/Domain/Exception/FileUploadException.php create mode 100644 src/Shared/Domain/Exception/UnsupportedMimeTypeException.php create mode 100644 src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php create mode 100644 src/Shared/Infrastructure/Upload/FileUploader.php create mode 100644 tests/Shared/Api/UploadedDocumentApiTest.php create mode 100644 tests/Shared/Infrastructure/Upload/FileUploaderTest.php diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 5689fb5..616caee 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -12,6 +12,9 @@ api_platform: # Resources virtuelles (sans entite Doctrine) declarees via #[ApiResource] # en dehors de Domain/Entity : AuditLogResource, etc. - '%kernel.project_dir%/src/Module/Core/Infrastructure/ApiPlatform/Resource' + # Entites techniques partagees portant un #[ApiResource] + # (UploadedDocument — infra upload generique ERP-154). + - '%kernel.project_dir%/src/Shared/Domain/Entity' formats: jsonld: ['application/ld+json'] json: ['application/json'] diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index a6a4377..84dd990 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -42,6 +42,16 @@ doctrine: # Shared sans importer la classe concrete du module Catalog (regle n°1). App\Shared\Domain\Contract\CategoryInterface: App\Module\Catalog\Domain\Entity\Category mappings: + # Mapping des entites techniques partagees (src/Shared/Domain/Entity). + # Premier occupant : UploadedDocument (infra upload generique ERP-154). + # Necessaire car les entites Shared ne sont pas couvertes par + # l'auto_mapping (qui ne cible que les bundles). + Shared: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src/Shared/Domain/Entity' + prefix: 'App\Shared\Domain\Entity' + alias: Shared Core: type: attribute is_bundle: false diff --git a/migrations/Version20260615130000.php b/migrations/Version20260615130000.php new file mode 100644 index 0000000..794c5a9 --- /dev/null +++ b/migrations/Version20260615130000.php @@ -0,0 +1,86 @@ +addSql(<<<'SQL' + CREATE TABLE uploaded_document ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + original_filename VARCHAR(255) NOT NULL, + stored_path VARCHAR(512) NOT NULL, + mime_type VARCHAR(100) NOT NULL, + size_bytes INT NOT NULL, + checksum VARCHAR(64) NOT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_uploaded_document_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + // Postgres n'indexe pas automatiquement les colonnes de FK. + $this->addSql('CREATE INDEX idx_uploaded_document_created_by ON uploaded_document (created_by)'); + // Recherche d'integrite / future deduplication par empreinte sha256. + $this->addSql('CREATE INDEX idx_uploaded_document_checksum ON uploaded_document (checksum)'); + + $this->addSql('COMMENT ON TABLE uploaded_document IS $_$Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.id IS $_$Identifiant interne auto-incremente.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.original_filename IS $_$Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.stored_path IS $_$Chemin relatif du fichier sous var/uploads (ex: 2026/06/.pdf) — nom genere aleatoirement, jamais le nom client.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.mime_type IS $_$Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.size_bytes IS $_$Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.checksum IS $_$Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.created_at IS $_$Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).$_$'); + $this->addSql('COMMENT ON COLUMN uploaded_document.created_by IS $_$ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.$_$'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE IF EXISTS uploaded_document'); + } +} diff --git a/src/Shared/Domain/Entity/UploadedDocument.php b/src/Shared/Domain/Entity/UploadedDocument.php new file mode 100644 index 0000000..f393127 --- /dev/null +++ b/src/Shared/Domain/Entity/UploadedDocument.php @@ -0,0 +1,160 @@ + 422. + * - Get (/uploaded_documents/{id}) : necessaire pour qu'API Platform genere + * l'IRI renvoyee par le Post. Securisee par l'authentification globale /api. + * + * Pas de GetCollection exposee (non requise) — la regle de pagination ne + * s'applique donc pas ici. + */ +#[ORM\Entity] +#[ORM\Table(name: 'uploaded_document')] +#[ApiResource( + operations: [ + new Get( + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + new Post( + // Entree multipart : le binaire arrive en multipart/form-data sous + // le champ « file ». Sans cet inputFormats, API Platform rejette la + // requete en 415. + inputFormats: ['multipart' => ['multipart/form-data']], + // Le fichier n'est pas deserialisable dans l'entite : le processor + // lit le binaire de la requete. La validation est portee par le + // FileUploader (MIME server-side, taille), pas par les contraintes. + deserialize: false, + validate: false, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + processor: UploadedDocumentProcessor::class, + ), + ], + normalizationContext: ['groups' => ['uploaded_document:read']], +)] +class UploadedDocument +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Groups(['uploaded_document:read'])] + private ?int $id = null; + + #[ORM\Column(name: 'original_filename', length: 255)] + #[Groups(['uploaded_document:read'])] + private string $originalFilename; + + #[ORM\Column(name: 'stored_path', length: 512)] + #[Groups(['uploaded_document:read'])] + private string $storedPath; + + #[ORM\Column(name: 'mime_type', length: 100)] + #[Groups(['uploaded_document:read'])] + private string $mimeType; + + #[ORM\Column(name: 'size_bytes', type: 'integer')] + #[Groups(['uploaded_document:read'])] + private int $sizeBytes; + + #[ORM\Column(name: 'checksum', length: 64)] + #[Groups(['uploaded_document:read'])] + private string $checksum; + + #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] + #[Groups(['uploaded_document:read'])] + private DateTimeImmutable $createdAt; + + #[ORM\ManyToOne(targetEntity: UserInterface::class)] + #[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?UserInterface $createdBy = null; + + public function __construct( + string $originalFilename, + string $storedPath, + string $mimeType, + int $sizeBytes, + string $checksum, + DateTimeImmutable $createdAt, + ) { + $this->originalFilename = $originalFilename; + $this->storedPath = $storedPath; + $this->mimeType = $mimeType; + $this->sizeBytes = $sizeBytes; + $this->checksum = $checksum; + $this->createdAt = $createdAt; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getOriginalFilename(): string + { + return $this->originalFilename; + } + + public function getStoredPath(): string + { + return $this->storedPath; + } + + public function getMimeType(): string + { + return $this->mimeType; + } + + public function getSizeBytes(): int + { + return $this->sizeBytes; + } + + public function getChecksum(): string + { + return $this->checksum; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getCreatedBy(): ?UserInterface + { + return $this->createdBy; + } + + public function setCreatedBy(?UserInterface $user): void + { + $this->createdBy = $user; + } +} diff --git a/src/Shared/Domain/Exception/FileTooLargeException.php b/src/Shared/Domain/Exception/FileTooLargeException.php new file mode 100644 index 0000000..d77c4a4 --- /dev/null +++ b/src/Shared/Domain/Exception/FileTooLargeException.php @@ -0,0 +1,21 @@ + $allowed Types MIME autorises + */ + public function __construct(string $mimeType, array $allowed) + { + parent::__construct(sprintf( + 'Le type de fichier « %s » n\'est pas autorisé. Types acceptés : %s.', + $mimeType, + implode(', ', $allowed), + )); + } +} diff --git a/src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php b/src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php new file mode 100644 index 0000000..b4a5398 --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/State/UploadedDocumentProcessor.php @@ -0,0 +1,68 @@ + 422 ; + * - MIME hors whitelist / fichier trop volumineux (FileUploadException) -> 422. + * + * @implements ProcessorInterface + */ +final class UploadedDocumentProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly FileUploader $fileUploader, + private readonly RequestStack $requestStack, + private readonly Security $security, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + $request = $this->requestStack->getCurrentRequest(); + $file = $request?->files->get('file'); + + if (!$file instanceof UploadedFile) { + throw new UnprocessableEntityHttpException('Aucun fichier fourni (champ « file » attendu).'); + } + + try { + $document = $this->fileUploader->upload($file); + } catch (FileUploadException $e) { + // MIME hors whitelist ou fichier trop volumineux -> 422 avec le + // message metier explicite porte par l'exception. + throw new UnprocessableEntityHttpException($e->getMessage(), $e); + } + + $user = $this->security->getUser(); + if ($user instanceof UserInterface) { + $document->setCreatedBy($user); + } + + return $this->persistProcessor->process($document, $operation, $uriVariables, $context); + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index a6f6dee..05298a7 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -36,6 +36,18 @@ final class ColumnCommentsCatalog public static function comments(): array { return [ + 'uploaded_document' => [ + '_table' => 'Fichiers televerses (infra generique Shared, ERP-154) — documents immuables (PDF / images), 1er consommateur la Decharge M4.', + 'id' => 'Identifiant interne auto-incremente.', + 'original_filename' => 'Nom de fichier d origine fourni par le client (≤ 255) — metadonnee d affichage uniquement, jamais utilise pour le stockage disque.', + 'stored_path' => 'Chemin relatif du fichier sous var/uploads (ex: 2026/06/.pdf) — nom genere aleatoirement, jamais le nom client.', + 'mime_type' => 'Type MIME detecte SERVER-SIDE via getMimeType (jamais getClientMimeType, spoofable) — borne a la whitelist FileUploader (PDF + images).', + 'size_bytes' => 'Taille du fichier en octets — bornee par FileUploader::MAX_SIZE_BYTES.', + 'checksum' => 'Empreinte SHA-256 du contenu (64 caracteres hex) — controle d integrite + deduplication eventuelle (hors scope).', + 'created_at' => 'Horodatage UTC du televersement — rempli par FileUploader via l horloge injectee (pas via TimestampableBlamableSubscriber).', + 'created_by' => 'ID de l utilisateur ayant televerse le fichier — null hors HTTP (CLI, fixture). FK -> "user".id, ON DELETE SET NULL.', + ], + 'audit_log' => [ '_table' => "Journal d'audit append-only — trace toutes les modifications BDD sur entites annotees #[Auditable]. Lecture seule via API.", 'id' => "UUID v7 — identifiant de la ligne d'audit (genere en PHP, ordre temporel garanti).", diff --git a/src/Shared/Infrastructure/Upload/FileUploader.php b/src/Shared/Infrastructure/Upload/FileUploader.php new file mode 100644 index 0000000..eef52d7 --- /dev/null +++ b/src/Shared/Infrastructure/Upload/FileUploader.php @@ -0,0 +1,107 @@ + + */ + public const ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', + ]; + + /** + * Taille maximale autorisee : 10 Mo. + */ + public const MAX_SIZE_BYTES = 10 * 1024 * 1024; + + public function __construct( + // Racine de stockage des fichiers televerses (hors web root, sous var/). + #[Autowire('%kernel.project_dir%/var/uploads')] + private readonly string $uploadBaseDir, + private readonly ClockInterface $clock, + ) {} + + /** + * Valide, calcule l empreinte, deplace le fichier sur disque et retourne + * un UploadedDocument NON persiste (le caller le persiste). + * + * @throws UnsupportedMimeTypeException si le MIME server-side est hors whitelist + * @throws FileTooLargeException si le fichier depasse MAX_SIZE_BYTES + */ + public function upload(UploadedFile $file): UploadedDocument + { + // Detection MIME server-side (finfo sur le contenu) — jamais le MIME + // declare par le client. + $mimeType = $file->getMimeType() ?? 'application/octet-stream'; + if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { + throw new UnsupportedMimeTypeException($mimeType, self::ALLOWED_MIME_TYPES); + } + + // getSize() peut renvoyer false si le fichier est illisible. + $size = $file->getSize(); + if (false === $size || $size > self::MAX_SIZE_BYTES) { + throw new FileTooLargeException(false === $size ? 0 : $size, self::MAX_SIZE_BYTES); + } + + // Checksum AVANT le move : le chemin du fichier change apres deplacement. + $checksum = hash_file('sha256', $file->getPathname()); + + $now = $this->clock->now(); + $relativeDir = $now->format('Y').'/'.$now->format('m'); + $targetDir = $this->uploadBaseDir.'/'.$relativeDir; + + // Nom de stockage genere aleatoirement : evite les collisions et toute + // injection via le nom client. Extension deduite du MIME. + $extension = $file->guessExtension() ?: 'bin'; + $storedName = bin2hex(random_bytes(16)).'.'.$extension; + + // Le nom d origine est conserve uniquement comme metadonnee d affichage, + // borne a la longueur de colonne (255). + $originalFilename = mb_substr($file->getClientOriginalName(), 0, 255); + + $file->move($targetDir, $storedName); + + return new UploadedDocument( + originalFilename: $originalFilename, + storedPath: $relativeDir.'/'.$storedName, + mimeType: $mimeType, + sizeBytes: $size, + checksum: $checksum, + createdAt: $now, + ); + } +} diff --git a/tests/Shared/Api/UploadedDocumentApiTest.php b/tests/Shared/Api/UploadedDocumentApiTest.php new file mode 100644 index 0000000..30bd5b8 --- /dev/null +++ b/tests/Shared/Api/UploadedDocumentApiTest.php @@ -0,0 +1,132 @@ + 201, IRI renvoyee, ligne persistee, + * checksum sha256 calcule cote serveur ; + * - POST d'un MIME hors whitelist (text/plain) -> 422 ; + * - POST sans fichier -> 422 ; + * - POST anonyme -> 401 (acces /api protege globalement). + * + * @internal + */ +final class UploadedDocumentApiTest extends AbstractApiTestCase +{ + private const string ENDPOINT = '/api/uploaded_documents'; + + /** @var list */ + private array $tempFiles = []; + + protected function tearDown(): void + { + foreach ($this->tempFiles as $path) { + if (is_file($path)) { + @unlink($path); + } + } + parent::tearDown(); + } + + public function testUploadValidPdfReturnsIriAndPersistsRowWithChecksum(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $content = $this->minimalPdf(); + $file = $this->makeUploadedFile($content, 'facture.pdf'); + + $response = $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => ['file' => $file]], + ]); + + self::assertResponseStatusCodeSame(201); + + $data = $response->toArray(); + + self::assertArrayHasKey('@id', $data); + self::assertStringStartsWith(self::ENDPOINT.'/', $data['@id']); + self::assertSame('facture.pdf', $data['originalFilename']); + self::assertSame('application/pdf', $data['mimeType']); + self::assertSame(\strlen($content), $data['sizeBytes']); + self::assertSame(hash('sha256', $content), $data['checksum']); + self::assertSame(64, \strlen($data['checksum'])); + + // La ligne est bien persistee et relisible via le repository. + $id = $data['id']; + $document = $this->getEm()->getRepository(UploadedDocument::class)->find($id); + self::assertInstanceOf(UploadedDocument::class, $document); + self::assertSame(hash('sha256', $content), $document->getChecksum()); + } + + public function testUploadDisallowedMimeTypeReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + $file = $this->makeUploadedFile('just some plain text content', 'note.txt'); + + $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => ['file' => $file]], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testUploadWithoutFileReturns422(): void + { + $client = $this->authenticatedClient('admin', 'admin'); + + $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => []], + ]); + + self::assertResponseStatusCodeSame(422); + } + + public function testUploadAnonymousIsRejected(): void + { + $client = self::createClient(); + $file = $this->makeUploadedFile($this->minimalPdf(), 'facture.pdf'); + + $client->request('POST', self::ENDPOINT, [ + 'headers' => ['Accept' => 'application/ld+json'], + 'extra' => ['files' => ['file' => $file]], + ]); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Cree un UploadedFile en mode test (move() autorise hors contexte HTTP). + */ + private function makeUploadedFile(string $content, string $clientName): UploadedFile + { + $path = sys_get_temp_dir().'/erp154-api-'.bin2hex(random_bytes(4)); + file_put_contents($path, $content); + $this->tempFiles[] = $path; + + return new UploadedFile($path, $clientName, null, null, true); + } + + /** + * Contenu PDF minimal valide (entete `%PDF-1.4` -> finfo `application/pdf`). + */ + private function minimalPdf(): string + { + return "%PDF-1.4\n" + ."1 0 obj<>endobj\n" + ."2 0 obj<>endobj\n" + ."3 0 obj<>endobj\n" + ."trailer<>\n" + ."%%EOF\n"; + } +} diff --git a/tests/Shared/Infrastructure/Upload/FileUploaderTest.php b/tests/Shared/Infrastructure/Upload/FileUploaderTest.php new file mode 100644 index 0000000..8f78f5a --- /dev/null +++ b/tests/Shared/Infrastructure/Upload/FileUploaderTest.php @@ -0,0 +1,144 @@ + */ + private array $tempFiles = []; + + protected function setUp(): void + { + $this->uploadBaseDir = sys_get_temp_dir().'/erp154-uploads-'.bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + // Nettoyage des fichiers sources et de l'arborescence de destination. + foreach ($this->tempFiles as $path) { + if (is_file($path)) { + @unlink($path); + } + } + $this->removeDirectory($this->uploadBaseDir); + } + + public function testRejectsMimeTypeOutsideWhitelist(): void + { + $uploader = $this->createUploader(); + $file = $this->makeUploadedFile('hello world plain text', 'note.txt'); + + $this->expectException(UnsupportedMimeTypeException::class); + + $uploader->upload($file); + } + + public function testRejectsFileLargerThanMaxSize(): void + { + $uploader = $this->createUploader(); + + // Contenu PDF valide mais artificiellement gonfle au-dela de la borne. + $content = "%PDF-1.4\n".str_repeat('A', FileUploader::MAX_SIZE_BYTES + 1); + $file = $this->makeUploadedFile($content, 'huge.pdf'); + + $this->expectException(FileTooLargeException::class); + + $uploader->upload($file); + } + + public function testStoresPdfAndComputesSha256Checksum(): void + { + $content = $this->minimalPdf(); + $clock = new MockClock(new DateTimeImmutable('2026-06-15 10:00:00')); + $uploader = $this->createUploader($clock); + $file = $this->makeUploadedFile($content, 'facture.pdf'); + + $document = $uploader->upload($file); + + self::assertSame('facture.pdf', $document->getOriginalFilename()); + self::assertSame('application/pdf', $document->getMimeType()); + self::assertSame(\strlen($content), $document->getSizeBytes()); + self::assertSame(hash('sha256', $content), $document->getChecksum()); + self::assertSame(64, \strlen($document->getChecksum())); + + // Chemin relatif date selon l'horloge injectee (2026/06). + self::assertStringStartsWith('2026/06/', $document->getStoredPath()); + self::assertFileExists($this->uploadBaseDir.'/'.$document->getStoredPath()); + + // Le fichier ecrit a bien le contenu d'origine (checksum coherent). + self::assertSame( + $document->getChecksum(), + hash_file('sha256', $this->uploadBaseDir.'/'.$document->getStoredPath()), + ); + } + + private function createUploader(?MockClock $clock = null): FileUploader + { + return new FileUploader($this->uploadBaseDir, $clock ?? new MockClock()); + } + + /** + * Cree un UploadedFile en mode test (move() autorise hors contexte HTTP). + */ + private function makeUploadedFile(string $content, string $clientName): UploadedFile + { + $path = sys_get_temp_dir().'/erp154-src-'.bin2hex(random_bytes(4)); + file_put_contents($path, $content); + $this->tempFiles[] = $path; + + // Le 5e argument `test: true` court-circuite move_uploaded_file(). + return new UploadedFile($path, $clientName, null, null, true); + } + + /** + * Contenu PDF minimal valide — l'entete `%PDF-1.4` suffit a faire detecter + * `application/pdf` par finfo (getMimeType server-side). + */ + private function minimalPdf(): string + { + return "%PDF-1.4\n" + ."1 0 obj<>endobj\n" + ."2 0 obj<>endobj\n" + ."3 0 obj<>endobj\n" + ."trailer<>\n" + ."%%EOF\n"; + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $items = scandir($dir) ?: []; + foreach ($items as $item) { + if ('.' === $item || '..' === $item) { + continue; + } + $path = $dir.'/'.$item; + is_dir($path) ? $this->removeDirectory($path) : @unlink($path); + } + @rmdir($dir); + } +}