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:
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user