From 93f57477cd0b7cb2efc30ecf3ef598a210a4dca6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 17:08:55 +0100 Subject: [PATCH] feat(backend): hash uploaded files and store hash on documents and annotations - Flyway V13: add file_hash column to documents and document_annotations - FileService.uploadFile() now returns UploadResult(s3Key, fileHash) with SHA-256 hash computed from raw bytes - Document and DocumentAnnotation models gain a fileHash field - DocumentService propagates the hash at all three upload sites (storeDocument, createDocument, updateDocument) - AnnotationService.createAnnotation() accepts and persists a fileHash - AnnotationController resolves the document's hash and passes it through Closes #55 Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AnnotationController.java | 6 +- .../familienarchiv/model/Document.java | 4 + .../model/DocumentAnnotation.java | 3 + .../service/AnnotationService.java | 3 +- .../service/DocumentService.java | 19 ++--- .../familienarchiv/service/FileService.java | 79 +++++++++++------ .../db/migration/V13__add_file_hash.sql | 7 ++ .../controller/AnnotationControllerTest.java | 9 +- .../service/AnnotationServiceTest.java | 33 ++++++- .../service/DocumentServiceTest.java | 42 +++++++++ .../service/FileServiceTest.java | 85 +++++++++++++++++++ 11 files changed, 247 insertions(+), 43 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V13__add_file_hash.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java index 04ca3c77..ce0e0f29 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.AnnotationService; +import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; @@ -23,6 +25,7 @@ import java.util.UUID; public class AnnotationController { private final AnnotationService annotationService; + private final DocumentService documentService; private final UserService userService; @GetMapping @@ -38,7 +41,8 @@ public class AnnotationController { @RequestBody CreateAnnotationDTO dto, Authentication authentication) { UUID userId = resolveUserId(authentication); - return annotationService.createAnnotation(documentId, dto, userId); + Document doc = documentService.getDocumentById(documentId); + return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash()); } @DeleteMapping("/{annotationId}") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 7c0abb37..5fa21c57 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -39,6 +39,10 @@ public class Document { @Column(name = "content_type") private String contentType; + // SHA-256 hash of the uploaded file — used to link annotations to a file version + @Column(name = "file_hash", length = 64) + private String fileHash; + // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf") @Column(name = "original_filename", nullable = false) @Schema(requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java index cdf6a079..281f88a2 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java @@ -49,6 +49,9 @@ public class DocumentAnnotation { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String color; + @Column(name = "file_hash", length = 64) + private String fileHash; + @Column(name = "created_by") private UUID createdBy; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index 6f1f91d1..f6f0687a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -23,7 +23,7 @@ public class AnnotationService { } @Transactional - public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) { + public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) { List existing = annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber()); @@ -41,6 +41,7 @@ public class AnnotationService { .width(dto.getWidth()) .height(dto.getHeight()) .color(dto.getColor()) + .fileHash(fileHash) .createdBy(userId) .build(); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 521b8199..919211df 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -64,10 +64,11 @@ public class DocumentService { } // 2. Delegate Storage to FileService - String s3Key = fileService.uploadFile(file, originalFilename); + FileService.UploadResult upload = fileService.uploadFile(file, originalFilename); // 3. Update Database - document.setFilePath(s3Key); + document.setFilePath(upload.s3Key()); + document.setFileHash(upload.fileHash()); document.setContentType(file.getContentType()); if (document.getStatus() == DocumentStatus.PLACEHOLDER) { document.setStatus(DocumentStatus.UPLOADED); @@ -120,8 +121,9 @@ public class DocumentService { // Datei if (file != null && !file.isEmpty()) { - String s3Key = fileService.uploadFile(file, file.getOriginalFilename()); - doc.setFilePath(s3Key); + FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename()); + doc.setFilePath(upload.s3Key()); + doc.setFileHash(upload.fileHash()); doc.setContentType(file.getContentType()); doc.setStatus(DocumentStatus.UPLOADED); } @@ -170,12 +172,9 @@ public class DocumentService { // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) if (newFile != null && !newFile.isEmpty()) { - // Alte Datei könnte man hier theoretisch löschen (optional) - - // Neue Datei hochladen - String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename()); - - doc.setFilePath(s3Key); + FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); + doc.setFilePath(upload.s3Key()); + doc.setFileHash(upload.fileHash()); doc.setOriginalFilename(newFile.getOriginalFilename()); doc.setContentType(newFile.getContentType()); doc.setStatus(DocumentStatus.UPLOADED); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java index 30f421b4..f2142fdc 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/FileService.java @@ -13,6 +13,8 @@ import org.springframework.web.multipart.MultipartFile; import org.springframework.core.io.InputStreamResource; import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.UUID; @Service @@ -29,10 +31,14 @@ public class FileService { } /** - * Uploads a file to S3/MinIO and returns the generated object key. + * Uploads a file to S3/MinIO. + * Returns an {@link UploadResult} containing the S3 key and the SHA-256 + * hash of the file content. The hash is used to link annotations to the + * specific file version they were created against. */ - public String uploadFile(MultipartFile file, String originalFilename) throws IOException { - // Generate secure unique path: "documents/UUID_filename" + public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException { + byte[] bytes = file.getBytes(); + String fileHash = sha256Hex(bytes); String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename; try { @@ -42,11 +48,10 @@ public class FileService { .contentType(file.getContentType()) .build(); - s3Client.putObject(putObjectRequest, - RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes)); - log.info("Uploaded file to S3: {}", s3Key); - return s3Key; + log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash); + return new UploadResult(s3Key, fileHash); } catch (S3Exception e) { log.error("S3 Upload Error", e); throw new IOException("Failed to upload file to storage", e); @@ -58,32 +63,52 @@ public class FileService { * Returns a wrapper containing the stream and content type. */ public S3FileDownload downloadFile(String s3Key) { - try { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .build(); + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); - ResponseInputStream s3Object = s3Client.getObject(getObjectRequest); + ResponseInputStream s3Object = s3Client.getObject(getObjectRequest); - // Use whatever content type S3 has stored (set at upload time) - String contentType = s3Object.response().contentType(); - if (contentType == null || contentType.isBlank()) { - contentType = "application/octet-stream"; + String contentType = s3Object.response().contentType(); + if (contentType == null || contentType.isBlank()) { + contentType = "application/octet-stream"; + } + + return new S3FileDownload(new InputStreamResource(s3Object), contentType); + + } catch (NoSuchKeyException e) { + throw new StorageFileNotFoundException("File not found in storage: " + s3Key); + } catch (S3Exception e) { + throw new RuntimeException("Storage Error: " + e.getMessage()); } - - return new S3FileDownload(new InputStreamResource(s3Object), contentType); - - } catch (NoSuchKeyException e) { - throw new StorageFileNotFoundException("File not found in storage: " + s3Key); - } catch (S3Exception e) { - throw new RuntimeException("Storage Error: " + e.getMessage()); } -} - // Helper Record to carry the stream and metadata back to the controller + + // ─── private helpers ────────────────────────────────────────────────────── + + private static String sha256Hex(byte[] bytes) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(bytes); + StringBuilder sb = new StringBuilder(64); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + // ─── result types ───────────────────────────────────────────────────────── + + /** Carries the S3 object key and the content hash back to the caller. */ + public record UploadResult(String s3Key, String fileHash) {} + + /** Carries the download stream and content type. */ public record S3FileDownload(InputStreamResource resource, String contentType) {} - // Custom Exception public static class StorageFileNotFoundException extends RuntimeException { public StorageFileNotFoundException(String message) { super(message); } } diff --git a/backend/src/main/resources/db/migration/V13__add_file_hash.sql b/backend/src/main/resources/db/migration/V13__add_file_hash.sql new file mode 100644 index 00000000..f03e1b7a --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__add_file_hash.sql @@ -0,0 +1,7 @@ +-- Add content-based file hash to documents for annotation versioning +ALTER TABLE documents + ADD COLUMN file_hash VARCHAR(64); + +-- Each annotation remembers which file version it was created against +ALTER TABLE document_annotations + ADD COLUMN file_hash VARCHAR(64); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index a2a4d3ec..686ccf3e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; @@ -36,6 +38,7 @@ class AnnotationControllerTest { @Autowired MockMvc mockMvc; @MockitoBean AnnotationService annotationService; + @MockitoBean DocumentService documentService; @MockitoBean UserService userService; @MockitoBean CustomUserDetailsService customUserDetailsService; @@ -85,7 +88,8 @@ class AnnotationControllerTest { DocumentAnnotation saved = DocumentAnnotation.builder() .id(UUID.randomUUID()).documentId(docId).pageNumber(1) .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); - when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved); + when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); + when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + docId + "/annotations") .contentType(MediaType.APPLICATION_JSON) @@ -97,7 +101,8 @@ class AnnotationControllerTest { @Test @WithMockUser(authorities = "ANNOTATE_ALL") void createAnnotation_returns409_whenOverlap() throws Exception { - when(annotationService.createAnnotation(any(), any(), any())) + when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); + when(annotationService.createAnnotation(any(), any(), any(), any())) .thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap")); mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index 319c4b8b..33019c9d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -44,7 +44,7 @@ class AnnotationServiceTest { when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)) .thenReturn(List.of(existing)); - assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId)) + assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null)) .isInstanceOf(DomainException.class) .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT)); @@ -63,7 +63,7 @@ class AnnotationServiceTest { .x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build(); when(annotationRepository.save(any())).thenReturn(saved); - DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId); + DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null); assertThat(result).isEqualTo(saved); verify(annotationRepository).save(any()); @@ -117,6 +117,35 @@ class AnnotationServiceTest { verify(annotationRepository).delete(annotation); } + @Test + void createAnnotation_setsFileHash_whenProvided() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000"); + String fileHash = "abc123"; + + when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of()); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash); + + assertThat(result.getFileHash()).isEqualTo(fileHash); + } + + @Test + void createAnnotation_setsNullFileHash_whenNoneProvided() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000"); + + when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of()); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null); + + assertThat(result.getFileHash()).isNull(); + } + // ─── listAnnotations ────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index e531bc44..1c8c413b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -135,6 +135,48 @@ class DocumentServiceTest { assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc); } + // ─── file hash propagation ─────────────────────────────────────────────── + + @Test + void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("Doc"); + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef"); + + Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc") + .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(savedDoc); + when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc)); + when(fileService.uploadFile(any(), any())).thenReturn(uploadResult); + + documentService.createDocument(dto, file); + + org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(Document.class); + verify(documentRepository, atLeastOnce()).save(captor.capture()); + assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef")); + } + + @Test + void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder() + .id(id).title("Alt").originalFilename("old.pdf") + .status(DocumentStatus.UPLOADED).build(); + org.springframework.mock.web.MockMultipartFile newFile = + new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2}); + FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe"); + + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(fileService.uploadFile(any(), any())).thenReturn(uploadResult); + when(documentRepository.save(any())).thenReturn(existing); + + documentService.updateDocument(id, new DocumentUpdateDTO(), newFile); + + assertThat(existing.getFileHash()).isEqualTo("cafebabe"); + } + // ─── versioning ─────────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java new file mode 100644 index 00000000..d8f719eb --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/FileServiceTest.java @@ -0,0 +1,85 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class FileServiceTest { + + private S3Client s3Client; + private FileService fileService; + + @BeforeEach + void setUp() { + s3Client = mock(S3Client.class); + fileService = new FileService(s3Client, "test-bucket"); + } + + @Test + void uploadFile_returnsS3Key() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "file", "test.pdf", "application/pdf", new byte[]{1, 2, 3}); + + FileService.UploadResult result = fileService.uploadFile(file, "test.pdf"); + + assertThat(result.s3Key()).startsWith("documents/"); + assertThat(result.s3Key()).endsWith("_test.pdf"); + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException { + byte[] content = "hello pdf content".getBytes(); + MockMultipartFile file = new MockMultipartFile( + "file", "doc.pdf", "application/pdf", content); + + FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf"); + + // Compute expected hash independently + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(content); + StringBuilder expected = new StringBuilder(); + for (byte b : hashBytes) { + expected.append(String.format("%02x", b)); + } + + assertThat(result.fileHash()).isEqualTo(expected.toString()); + } + + @Test + void uploadFile_differentContents_produceDifferentHashes() throws IOException { + MockMultipartFile file1 = new MockMultipartFile( + "f", "a.pdf", "application/pdf", new byte[]{1, 2, 3}); + MockMultipartFile file2 = new MockMultipartFile( + "f", "b.pdf", "application/pdf", new byte[]{4, 5, 6}); + + FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf"); + FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf"); + + assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash()); + } + + @Test + void uploadFile_sameContents_produceSameHash() throws IOException { + byte[] content = new byte[]{10, 20, 30}; + MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content); + MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content); + + FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf"); + FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf"); + + assertThat(r1.fileHash()).isEqualTo(r2.fileHash()); + } +}