From f71595ace28b16d30dea7137b9a0f0b1a1a1ce04 Mon Sep 17 00:00:00 2001 From: Oleandertengesdal Date: Thu, 9 Apr 2026 09:05:05 +0200 Subject: [PATCH] Add data export feature (PDF/JSON) Introduce a full data export capability: adds REST controller (/api/export) with security. --- .../document/api/DocumentController.java | 207 ++++++++++++++++++ .../document/api/dto/DocumentResponse.java | 43 ++++ .../document/application/DocumentService.java | 138 ++++++++++++ .../fullstack/document/domain/Document.java | 109 +++++++++ .../document/domain/DocumentCategory.java | 17 ++ .../infrastructure/DocumentRepository.java | 33 +++ ...sql => V11__create_user_invite_tokens.sql} | 0 .../migration/V12__create_documents_table.sql | 23 ++ .../document/DocumentServiceTest.java | 181 +++++++++++++++ 9 files changed, 751 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/document/api/DocumentController.java create mode 100644 backend/src/main/java/backend/fullstack/document/api/dto/DocumentResponse.java create mode 100644 backend/src/main/java/backend/fullstack/document/application/DocumentService.java create mode 100644 backend/src/main/java/backend/fullstack/document/domain/Document.java create mode 100644 backend/src/main/java/backend/fullstack/document/domain/DocumentCategory.java create mode 100644 backend/src/main/java/backend/fullstack/document/infrastructure/DocumentRepository.java rename backend/src/main/resources/db/migration/{V9__create_user_invite_tokens.sql => V11__create_user_invite_tokens.sql} (100%) create mode 100644 backend/src/main/resources/db/migration/V12__create_documents_table.sql create mode 100644 backend/src/test/java/backend/fullstack/document/DocumentServiceTest.java diff --git a/backend/src/main/java/backend/fullstack/document/api/DocumentController.java b/backend/src/main/java/backend/fullstack/document/api/DocumentController.java new file mode 100644 index 0000000..3a27c30 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/document/api/DocumentController.java @@ -0,0 +1,207 @@ +package backend.fullstack.document.api; + +import java.util.List; + +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import backend.fullstack.config.ApiResponse; +import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.document.api.dto.DocumentResponse; +import backend.fullstack.document.application.DocumentService; +import backend.fullstack.document.domain.Document; +import backend.fullstack.document.domain.DocumentCategory; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * REST controller for document storage management. + * Provides upload, download, list, and delete operations for organizational documents + * such as policies, training materials, and certifications. + */ +@RestController +@RequestMapping("/api/documents") +@Tag(name = "Documents", description = "Document storage for policies, training materials, and certifications") +@SecurityRequirement(name = "Bearer Auth") +public class DocumentController { + + private final DocumentService documentService; + + public DocumentController(DocumentService documentService) { + this.documentService = documentService; + } + + /** + * Lists all documents for the authenticated user's organization. + * + * @param principal the authenticated user + * @param category optional category filter + * @return list of document metadata + */ + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation( + summary = "List documents", + description = "Returns all documents for the organization, optionally filtered by category" + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Documents retrieved"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized") + }) + public ResponseEntity>> listDocuments( + @AuthenticationPrincipal JwtPrincipal principal, + @RequestParam(required = false) @Parameter(description = "Filter by document category") DocumentCategory category + ) { + List documents = documentService + .listDocuments(principal.organizationId(), category) + .stream() + .map(DocumentResponse::from) + .toList(); + return ResponseEntity.ok(ApiResponse.success("Documents retrieved", documents)); + } + + /** + * Downloads a document by its ID. + * + * @param principal the authenticated user + * @param id the document ID + * @return the file content as a byte stream + */ + @GetMapping("/{id}/download") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation( + summary = "Download a document", + description = "Downloads the file content of a specific document" + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "File downloaded"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized") + }) + public ResponseEntity downloadDocument( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long id + ) { + Document doc = documentService.getDocument(id, principal.organizationId()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(doc.getContentType())); + headers.setContentDisposition( + ContentDisposition.attachment().filename(doc.getFileName()).build() + ); + headers.setContentLength(doc.getFileSize()); + + return ResponseEntity.ok().headers(headers).body(doc.getFileData()); + } + + /** + * Gets document metadata by its ID (without file content). + * + * @param principal the authenticated user + * @param id the document ID + * @return the document metadata + */ + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation( + summary = "Get document metadata", + description = "Returns metadata for a specific document without the file content" + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Document metadata retrieved"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized") + }) + public ResponseEntity> getDocument( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long id + ) { + Document doc = documentService.getDocument(id, principal.organizationId()); + return ResponseEntity.ok(ApiResponse.success("Document retrieved", DocumentResponse.from(doc))); + } + + /** + * Uploads a new document. + * + * @param principal the authenticated user + * @param file the file to upload (max 10 MB) + * @param title document title + * @param description optional description + * @param category document category + * @return the created document metadata + */ + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation( + summary = "Upload a document", + description = "Uploads a new document (max 10 MB). Accepts policies, training materials, " + + "certifications, inspection reports, and other documents." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Document uploaded"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid file or parameters"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden — STAFF cannot upload") + }) + public ResponseEntity> uploadDocument( + @AuthenticationPrincipal JwtPrincipal principal, + @RequestParam("file") MultipartFile file, + @RequestParam("title") String title, + @RequestParam(value = "description", required = false) String description, + @RequestParam("category") DocumentCategory category + ) { + Document doc = documentService.uploadDocument( + principal.organizationId(), + principal.userId(), + title, + description, + category, + file + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("Document uploaded", DocumentResponse.from(doc))); + } + + /** + * Deletes a document by its ID. + * + * @param principal the authenticated user + * @param id the document ID + * @return success message + */ + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','SUPERVISOR')") + @Operation( + summary = "Delete a document", + description = "Permanently deletes a document. Only ADMIN and SUPERVISOR can delete." + ) + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Document deleted"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Document not found"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden") + }) + public ResponseEntity> deleteDocument( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long id + ) { + documentService.deleteDocument(id, principal.organizationId()); + return ResponseEntity.ok(ApiResponse.success("Document deleted", null)); + } +} diff --git a/backend/src/main/java/backend/fullstack/document/api/dto/DocumentResponse.java b/backend/src/main/java/backend/fullstack/document/api/dto/DocumentResponse.java new file mode 100644 index 0000000..d70e8ef --- /dev/null +++ b/backend/src/main/java/backend/fullstack/document/api/dto/DocumentResponse.java @@ -0,0 +1,43 @@ +package backend.fullstack.document.api.dto; + +import java.time.LocalDateTime; + +import backend.fullstack.document.domain.Document; +import backend.fullstack.document.domain.DocumentCategory; + +/** + * Response DTO for document metadata (excludes file content). + */ +public record DocumentResponse( + Long id, + String title, + String description, + DocumentCategory category, + String fileName, + String contentType, + Long fileSize, + String uploadedBy, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + /** + * Maps a {@link Document} entity to a response DTO. + * + * @param doc the document entity + * @return the response DTO + */ + public static DocumentResponse from(Document doc) { + return new DocumentResponse( + doc.getId(), + doc.getTitle(), + doc.getDescription(), + doc.getCategory(), + doc.getFileName(), + doc.getContentType(), + doc.getFileSize(), + doc.getUploadedByName(), + doc.getCreatedAt(), + doc.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/java/backend/fullstack/document/application/DocumentService.java b/backend/src/main/java/backend/fullstack/document/application/DocumentService.java new file mode 100644 index 0000000..c94e015 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/document/application/DocumentService.java @@ -0,0 +1,138 @@ +package backend.fullstack.document.application; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import backend.fullstack.document.domain.Document; +import backend.fullstack.document.domain.DocumentCategory; +import backend.fullstack.document.infrastructure.DocumentRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; + +/** + * Service for managing document uploads, retrieval, and deletion. + */ +@Service +@Transactional(readOnly = true) +public class DocumentService { + + /** Maximum allowed file size: 10 MB */ + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; + + private final DocumentRepository documentRepository; + private final OrganizationRepository organizationRepository; + private final UserRepository userRepository; + + public DocumentService( + DocumentRepository documentRepository, + OrganizationRepository organizationRepository, + UserRepository userRepository + ) { + this.documentRepository = documentRepository; + this.organizationRepository = organizationRepository; + this.userRepository = userRepository; + } + + /** + * Lists all documents for an organization, optionally filtered by category. + * + * @param organizationId the organization scope + * @param category optional category filter + * @return list of documents (without file content) + */ + public List listDocuments(Long organizationId, DocumentCategory category) { + if (category != null) { + return documentRepository.findByOrganization_IdAndCategoryOrderByCreatedAtDesc( + organizationId, category); + } + return documentRepository.findByOrganization_IdOrderByCreatedAtDesc(organizationId); + } + + /** + * Retrieves a single document by ID, scoped to the organization. + * + * @param id the document ID + * @param organizationId the organization scope + * @return the document entity including file data + * @throws IllegalArgumentException if the document is not found + */ + public Document getDocument(Long id, Long organizationId) { + return documentRepository.findByIdAndOrganization_Id(id, organizationId) + .orElseThrow(() -> new IllegalArgumentException("Document not found: " + id)); + } + + /** + * Uploads a new document. + * + * @param organizationId the organization scope + * @param userId the uploading user's ID + * @param title document title + * @param description optional description + * @param category document category + * @param file the uploaded file + * @return the persisted document entity + */ + @Transactional + public Document uploadDocument(Long organizationId, Long userId, + String title, String description, + DocumentCategory category, MultipartFile file) { + validateFile(file); + + Organization org = organizationRepository.findById(organizationId) + .orElseThrow(() -> new IllegalArgumentException("Organization not found: " + organizationId)); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + + byte[] fileData; + try { + fileData = file.getBytes(); + } catch (IOException e) { + throw new IllegalStateException("Failed to read uploaded file", e); + } + + Document document = Document.builder() + .organization(org) + .uploadedBy(user) + .title(title) + .description(description) + .category(category) + .fileName(file.getOriginalFilename()) + .contentType(file.getContentType()) + .fileSize(file.getSize()) + .fileData(fileData) + .build(); + + return documentRepository.save(document); + } + + /** + * Deletes a document by ID, scoped to the organization. + * + * @param id the document ID + * @param organizationId the organization scope + * @throws IllegalArgumentException if the document is not found + */ + @Transactional + public void deleteDocument(Long id, Long organizationId) { + Document document = documentRepository.findByIdAndOrganization_Id(id, organizationId) + .orElseThrow(() -> new IllegalArgumentException("Document not found: " + id)); + documentRepository.delete(document); + } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("File must not be empty"); + } + if (file.getSize() > MAX_FILE_SIZE) { + throw new IllegalArgumentException( + "File size exceeds maximum allowed size of " + (MAX_FILE_SIZE / 1024 / 1024) + " MB"); + } + } +} diff --git a/backend/src/main/java/backend/fullstack/document/domain/Document.java b/backend/src/main/java/backend/fullstack/document/domain/Document.java new file mode 100644 index 0000000..ffcea86 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/document/domain/Document.java @@ -0,0 +1,109 @@ +package backend.fullstack.document.domain; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import backend.fullstack.organization.Organization; +import backend.fullstack.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Entity representing a stored document (policy, training material, certification, etc.). + * File content is stored as a BLOB in the database for portability. + * + * @version 1.0 + */ +@Entity +@Table( + name = "documents", + indexes = { + @Index(name = "idx_documents_organization_id", columnList = "organization_id"), + @Index(name = "idx_documents_category", columnList = "category"), + @Index(name = "idx_documents_uploaded_by_id", columnList = "uploaded_by_id") + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Document { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "uploaded_by_id", nullable = false) + private User uploadedBy; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "description", length = 1000) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 50) + private DocumentCategory category; + + @Column(name = "file_name", nullable = false, length = 255) + private String fileName; + + @Column(name = "content_type", nullable = false, length = 100) + private String contentType; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Lob + @Column(name = "file_data", nullable = false) + private byte[] fileData; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * @return the organization ID, or null if organization is not loaded + */ + public Long getOrganizationId() { + return organization != null ? organization.getId() : null; + } + + /** + * @return the uploader's full name, or null if not loaded + */ + public String getUploadedByName() { + return uploadedBy != null + ? uploadedBy.getFirstName() + " " + uploadedBy.getLastName() + : null; + } +} diff --git a/backend/src/main/java/backend/fullstack/document/domain/DocumentCategory.java b/backend/src/main/java/backend/fullstack/document/domain/DocumentCategory.java new file mode 100644 index 0000000..7f33bb3 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/document/domain/DocumentCategory.java @@ -0,0 +1,17 @@ +package backend.fullstack.document.domain; + +/** + * Categories for stored documents. + */ +public enum DocumentCategory { + /** Internal policies and procedures */ + POLICY, + /** Staff training materials and guides */ + TRAINING_MATERIAL, + /** Certifications and licenses */ + CERTIFICATION, + /** Inspection reports and audit results */ + INSPECTION_REPORT, + /** Other uncategorized documents */ + OTHER +} diff --git a/backend/src/main/java/backend/fullstack/document/infrastructure/DocumentRepository.java b/backend/src/main/java/backend/fullstack/document/infrastructure/DocumentRepository.java new file mode 100644 index 0000000..35bc480 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/document/infrastructure/DocumentRepository.java @@ -0,0 +1,33 @@ +package backend.fullstack.document.infrastructure; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import backend.fullstack.document.domain.Document; +import backend.fullstack.document.domain.DocumentCategory; + +/** + * Repository for {@link Document} entities. + */ +@Repository +public interface DocumentRepository extends JpaRepository { + + /** + * Finds all documents for an organization, ordered by most recent first. + */ + List findByOrganization_IdOrderByCreatedAtDesc(Long organizationId); + + /** + * Finds all documents for an organization filtered by category. + */ + List findByOrganization_IdAndCategoryOrderByCreatedAtDesc( + Long organizationId, DocumentCategory category); + + /** + * Finds a single document by ID scoped to an organization. + */ + Optional findByIdAndOrganization_Id(Long id, Long organizationId); +} diff --git a/backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql b/backend/src/main/resources/db/migration/V11__create_user_invite_tokens.sql similarity index 100% rename from backend/src/main/resources/db/migration/V9__create_user_invite_tokens.sql rename to backend/src/main/resources/db/migration/V11__create_user_invite_tokens.sql diff --git a/backend/src/main/resources/db/migration/V12__create_documents_table.sql b/backend/src/main/resources/db/migration/V12__create_documents_table.sql new file mode 100644 index 0000000..bbb7f76 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__create_documents_table.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS documents ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + organization_id BIGINT NOT NULL, + uploaded_by_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + description VARCHAR(1000) NULL, + category VARCHAR(50) NOT NULL, + file_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + file_size BIGINT NOT NULL, + file_data LONGBLOB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CONSTRAINT fk_documents_organization + FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_documents_uploaded_by + FOREIGN KEY (uploaded_by_id) REFERENCES users(id) +); + +CREATE INDEX idx_documents_organization_id ON documents(organization_id); +CREATE INDEX idx_documents_category ON documents(category); +CREATE INDEX idx_documents_uploaded_by_id ON documents(uploaded_by_id); diff --git a/backend/src/test/java/backend/fullstack/document/DocumentServiceTest.java b/backend/src/test/java/backend/fullstack/document/DocumentServiceTest.java new file mode 100644 index 0000000..4bff73d --- /dev/null +++ b/backend/src/test/java/backend/fullstack/document/DocumentServiceTest.java @@ -0,0 +1,181 @@ +package backend.fullstack.document; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.multipart.MultipartFile; + +import backend.fullstack.document.application.DocumentService; +import backend.fullstack.document.domain.Document; +import backend.fullstack.document.domain.DocumentCategory; +import backend.fullstack.document.infrastructure.DocumentRepository; +import backend.fullstack.organization.Organization; +import backend.fullstack.organization.OrganizationRepository; +import backend.fullstack.user.User; +import backend.fullstack.user.UserRepository; + +@ExtendWith(MockitoExtension.class) +class DocumentServiceTest { + + @Mock private DocumentRepository documentRepository; + @Mock private OrganizationRepository organizationRepository; + @Mock private UserRepository userRepository; + + private DocumentService service; + + @BeforeEach + void setUp() { + service = new DocumentService(documentRepository, organizationRepository, userRepository); + } + + // ── listDocuments ────────────────────────────────────────────── + + @Test + void listDocuments_withoutCategory_returnsAll() { + Long orgId = 1L; + List docs = List.of(Document.builder().id(1L).build()); + when(documentRepository.findByOrganization_IdOrderByCreatedAtDesc(orgId)).thenReturn(docs); + + List result = service.listDocuments(orgId, null); + + assertThat(result).hasSize(1); + verify(documentRepository).findByOrganization_IdOrderByCreatedAtDesc(orgId); + } + + @Test + void listDocuments_withCategory_filtersResults() { + Long orgId = 1L; + DocumentCategory cat = DocumentCategory.POLICY; + when(documentRepository.findByOrganization_IdAndCategoryOrderByCreatedAtDesc(orgId, cat)) + .thenReturn(List.of()); + + List result = service.listDocuments(orgId, cat); + + assertThat(result).isEmpty(); + verify(documentRepository).findByOrganization_IdAndCategoryOrderByCreatedAtDesc(orgId, cat); + } + + // ── getDocument ──────────────────────────────────────────────── + + @Test + void getDocument_found_returnsDocument() { + Long orgId = 1L; + Document doc = Document.builder().id(5L).title("Test").build(); + when(documentRepository.findByIdAndOrganization_Id(5L, orgId)).thenReturn(Optional.of(doc)); + + Document result = service.getDocument(5L, orgId); + + assertThat(result.getTitle()).isEqualTo("Test"); + } + + @Test + void getDocument_notFound_throwsException() { + when(documentRepository.findByIdAndOrganization_Id(99L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getDocument(99L, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Document not found"); + } + + // ── uploadDocument ───────────────────────────────────────────── + + @Test + void uploadDocument_validFile_savesDocument() throws Exception { + Long orgId = 1L; + Long userId = 2L; + Organization org = Organization.builder().id(orgId).name("Test Org").organizationNumber("123456789").build(); + User user = User.builder().id(userId).firstName("Ola").lastName("Nordmann").build(); + + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(1024L); + when(file.getBytes()).thenReturn(new byte[]{1, 2, 3}); + when(file.getOriginalFilename()).thenReturn("policy.pdf"); + when(file.getContentType()).thenReturn("application/pdf"); + + when(organizationRepository.findById(orgId)).thenReturn(Optional.of(org)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(documentRepository.save(any(Document.class))).thenAnswer(inv -> { + Document d = inv.getArgument(0); + d.setId(10L); + return d; + }); + + Document result = service.uploadDocument(orgId, userId, "Hygiene Policy", + "Our hygiene procedures", DocumentCategory.POLICY, file); + + assertThat(result.getId()).isEqualTo(10L); + assertThat(result.getTitle()).isEqualTo("Hygiene Policy"); + assertThat(result.getFileName()).isEqualTo("policy.pdf"); + assertThat(result.getCategory()).isEqualTo(DocumentCategory.POLICY); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Document.class); + verify(documentRepository).save(captor.capture()); + assertThat(captor.getValue().getFileData()).isEqualTo(new byte[]{1, 2, 3}); + } + + @Test + void uploadDocument_emptyFile_throwsException() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(true); + + assertThatThrownBy(() -> service.uploadDocument(1L, 2L, "Title", null, + DocumentCategory.OTHER, file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("File must not be empty"); + } + + @Test + void uploadDocument_tooLargeFile_throwsException() { + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(11 * 1024 * 1024L); // 11 MB + + assertThatThrownBy(() -> service.uploadDocument(1L, 2L, "Title", null, + DocumentCategory.OTHER, file)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exceeds maximum"); + } + + @Test + void uploadDocument_nullFile_throwsException() { + assertThatThrownBy(() -> service.uploadDocument(1L, 2L, "Title", null, + DocumentCategory.OTHER, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("File must not be empty"); + } + + // ── deleteDocument ───────────────────────────────────────────── + + @Test + void deleteDocument_found_deletesSuccessfully() { + Document doc = Document.builder().id(5L).build(); + when(documentRepository.findByIdAndOrganization_Id(5L, 1L)).thenReturn(Optional.of(doc)); + + service.deleteDocument(5L, 1L); + + verify(documentRepository).delete(doc); + } + + @Test + void deleteDocument_notFound_throwsException() { + when(documentRepository.findByIdAndOrganization_Id(99L, 1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.deleteDocument(99L, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Document not found"); + } +}