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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-24 17:08:55 +01:00
parent 34c66f80fc
commit 93f57477cd
11 changed files with 247 additions and 43 deletions

View File

@@ -4,10 +4,12 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
@@ -23,6 +25,7 @@ import java.util.UUID;
public class AnnotationController { public class AnnotationController {
private final AnnotationService annotationService; private final AnnotationService annotationService;
private final DocumentService documentService;
private final UserService userService; private final UserService userService;
@GetMapping @GetMapping
@@ -38,7 +41,8 @@ public class AnnotationController {
@RequestBody CreateAnnotationDTO dto, @RequestBody CreateAnnotationDTO dto,
Authentication authentication) { Authentication authentication) {
UUID userId = resolveUserId(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}") @DeleteMapping("/{annotationId}")

View File

@@ -39,6 +39,10 @@ public class Document {
@Column(name = "content_type") @Column(name = "content_type")
private String contentType; 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") // Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
@Column(name = "original_filename", nullable = false) @Column(name = "original_filename", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)

View File

@@ -49,6 +49,9 @@ public class DocumentAnnotation {
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color; private String color;
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(name = "created_by") @Column(name = "created_by")
private UUID createdBy; private UUID createdBy;

View File

@@ -23,7 +23,7 @@ public class AnnotationService {
} }
@Transactional @Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) { public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
List<DocumentAnnotation> existing = List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber()); annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
@@ -41,6 +41,7 @@ public class AnnotationService {
.width(dto.getWidth()) .width(dto.getWidth())
.height(dto.getHeight()) .height(dto.getHeight())
.color(dto.getColor()) .color(dto.getColor())
.fileHash(fileHash)
.createdBy(userId) .createdBy(userId)
.build(); .build();

View File

@@ -64,10 +64,11 @@ public class DocumentService {
} }
// 2. Delegate Storage to FileService // 2. Delegate Storage to FileService
String s3Key = fileService.uploadFile(file, originalFilename); FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
// 3. Update Database // 3. Update Database
document.setFilePath(s3Key); document.setFilePath(upload.s3Key());
document.setFileHash(upload.fileHash());
document.setContentType(file.getContentType()); document.setContentType(file.getContentType());
if (document.getStatus() == DocumentStatus.PLACEHOLDER) { if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
document.setStatus(DocumentStatus.UPLOADED); document.setStatus(DocumentStatus.UPLOADED);
@@ -120,8 +121,9 @@ public class DocumentService {
// Datei // Datei
if (file != null && !file.isEmpty()) { if (file != null && !file.isEmpty()) {
String s3Key = fileService.uploadFile(file, file.getOriginalFilename()); FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
doc.setFilePath(s3Key); doc.setFilePath(upload.s3Key());
doc.setFileHash(upload.fileHash());
doc.setContentType(file.getContentType()); doc.setContentType(file.getContentType());
doc.setStatus(DocumentStatus.UPLOADED); doc.setStatus(DocumentStatus.UPLOADED);
} }
@@ -170,12 +172,9 @@ public class DocumentService {
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
if (newFile != null && !newFile.isEmpty()) { if (newFile != null && !newFile.isEmpty()) {
// Alte Datei könnte man hier theoretisch löschen (optional) FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(upload.s3Key());
// Neue Datei hochladen doc.setFileHash(upload.fileHash());
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
doc.setFilePath(s3Key);
doc.setOriginalFilename(newFile.getOriginalFilename()); doc.setOriginalFilename(newFile.getOriginalFilename());
doc.setContentType(newFile.getContentType()); doc.setContentType(newFile.getContentType());
doc.setStatus(DocumentStatus.UPLOADED); doc.setStatus(DocumentStatus.UPLOADED);

View File

@@ -13,6 +13,8 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.InputStreamResource;
import java.io.IOException; import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID; import java.util.UUID;
@Service @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 { public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
// Generate secure unique path: "documents/UUID_filename" byte[] bytes = file.getBytes();
String fileHash = sha256Hex(bytes);
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename; String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
try { try {
@@ -42,11 +48,10 @@ public class FileService {
.contentType(file.getContentType()) .contentType(file.getContentType())
.build(); .build();
s3Client.putObject(putObjectRequest, s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
log.info("Uploaded file to S3: {}", s3Key); log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
return s3Key; return new UploadResult(s3Key, fileHash);
} catch (S3Exception e) { } catch (S3Exception e) {
log.error("S3 Upload Error", e); log.error("S3 Upload Error", e);
throw new IOException("Failed to upload file to storage", 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. * Returns a wrapper containing the stream and content type.
*/ */
public S3FileDownload downloadFile(String s3Key) { public S3FileDownload downloadFile(String s3Key) {
try { try {
GetObjectRequest getObjectRequest = GetObjectRequest.builder() GetObjectRequest getObjectRequest = GetObjectRequest.builder()
.bucket(bucketName) .bucket(bucketName)
.key(s3Key) .key(s3Key)
.build(); .build();
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest); ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
// Use whatever content type S3 has stored (set at upload time) String contentType = s3Object.response().contentType();
String contentType = s3Object.response().contentType(); if (contentType == null || contentType.isBlank()) {
if (contentType == null || contentType.isBlank()) { contentType = "application/octet-stream";
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) {} public record S3FileDownload(InputStreamResource resource, String contentType) {}
// Custom Exception
public static class StorageFileNotFoundException extends RuntimeException { public static class StorageFileNotFoundException extends RuntimeException {
public StorageFileNotFoundException(String message) { super(message); } public StorageFileNotFoundException(String message) { super(message); }
} }

View File

@@ -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);

View File

@@ -4,10 +4,12 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.UserService; import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
@@ -36,6 +38,7 @@ class AnnotationControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService; @MockitoBean AnnotationService annotationService;
@MockitoBean DocumentService documentService;
@MockitoBean UserService userService; @MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
@@ -85,7 +88,8 @@ class AnnotationControllerTest {
DocumentAnnotation saved = DocumentAnnotation.builder() DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1) .id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); .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") mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -97,7 +101,8 @@ class AnnotationControllerTest {
@Test @Test
@WithMockUser(authorities = "ANNOTATE_ALL") @WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception { 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")); .thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")

View File

@@ -44,7 +44,7 @@ class AnnotationServiceTest {
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)) when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing)); .thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId)) assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
.isInstanceOf(DomainException.class) .isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT)); .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(); .x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved); 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); assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any()); verify(annotationRepository).save(any());
@@ -117,6 +117,35 @@ class AnnotationServiceTest {
verify(annotationRepository).delete(annotation); 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 ────────────────────────────────────────────────────── // ─── listAnnotations ──────────────────────────────────────────────────────
@Test @Test

View File

@@ -135,6 +135,48 @@ class DocumentServiceTest {
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc); 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<Document> 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 ─────────────────────────────────────────────────────────── // ─── versioning ───────────────────────────────────────────────────────────
@Test @Test

View File

@@ -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());
}
}