From 9ead43e8eadadace9c197e8fd49a9aa5628b4fdc Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:18:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B3=80=ED=99=98=20=EC=9E=91=EC=97=85?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 변환 작업 및 관련 생성된 아티팩트를 삭제할 수 있는 `DELETE /api/v1/convert/jobs/{jobId}` 엔드포인트를 추가했습니다. 상세 변경 사항: - `TenantPermissions`에 `JOB_DELETE` 권한 추가 - `ArtifactStore` 및 `InMemoryArtifactStore`에 `deletePdf(UUID docId)` 메서드 추가 - `ConversionJobRepository` 및 `InMemoryConversionJobRepository`에 `deleteById(UUID jobId)` 메서드 추가 - `DocumentConversionService` 및 `DefaultDocumentConversionService`에 `deleteJob(UUID jobId)` 메서드 추가 - `ConversionController`에 `deleteJob` 엔드포인트 구현 및 테스트 케이스 추가 - 테스트 코드에서 변경된 인터페이스 및 생성자 시그니처 반영 및 컴파일 오류 수정 --- .../viewer/artifact/ArtifactStore.java | 7 +++ .../artifact/InMemoryArtifactStore.java | 8 +++ .../viewer/auth/TenantPermissions.java | 5 ++ .../controller/ConversionController.java | 25 ++++++++ .../repository/ConversionJobRepository.java | 7 +++ .../InMemoryConversionJobRepository.java | 11 ++++ .../DefaultDocumentConversionService.java | 8 +++ .../service/DocumentConversionService.java | 7 +++ ...onversionControllerMultipartLimitTest.java | 1 + .../controller/ConversionControllerTest.java | 60 +++++++++++++++++++ .../ConversionJobRepositoryTest.java | 4 ++ .../service/DefaultConversionWorkerTest.java | 5 ++ .../DefaultDocumentConversionServiceTest.java | 5 ++ .../ServiceInterfaceDefaultMethodsTest.java | 8 +++ 14 files changed, 161 insertions(+) diff --git a/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java index f40858a..61e8e11 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/ArtifactStore.java @@ -26,4 +26,11 @@ public interface ArtifactStore { * @return PDF bytes when present */ Optional getPdf(UUID docId); + + /** + * Deletes stored PDF bytes for a document. + * + * @param docId document identifier + */ + void deletePdf(UUID docId); } diff --git a/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java b/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java index 7bc447b..08ba351 100644 --- a/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java +++ b/src/main/java/com/clearfolio/viewer/artifact/InMemoryArtifactStore.java @@ -33,4 +33,12 @@ public Optional getPdf(UUID docId) { } return Optional.of(bytes.clone()); } + + /** + * {@inheritDoc} + */ + @Override + public void deletePdf(UUID docId) { + pdfByDocId.remove(docId); + } } diff --git a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java index 451c5e8..ced5e6a 100644 --- a/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java +++ b/src/main/java/com/clearfolio/viewer/auth/TenantPermissions.java @@ -15,6 +15,11 @@ public final class TenantPermissions { */ public static final String JOB_READ = "job:read"; + /** + * Permission required to delete a conversion job. + */ + public static final String JOB_DELETE = "job:delete"; + /** * Permission required to retry a dead-lettered conversion job. */ diff --git a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java index 9b73c74..b4d3603 100644 --- a/src/main/java/com/clearfolio/viewer/controller/ConversionController.java +++ b/src/main/java/com/clearfolio/viewer/controller/ConversionController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.http.codec.multipart.FilePart; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.bind.annotation.DeleteMapping; import com.clearfolio.viewer.auth.TenantAccessService; import com.clearfolio.viewer.auth.TenantContext; @@ -51,6 +52,7 @@ public class ConversionController { private final DocumentConversionService conversionService; private final TenantAccessService tenantAccessService; private final ArtifactLinkService artifactLinkService; + private final com.clearfolio.viewer.artifact.ArtifactStore artifactStore; private final int maxInMemorySizeBytes; /** @@ -59,16 +61,19 @@ public class ConversionController { * @param conversionService conversion service * @param tenantAccessService tenant and permission guard * @param artifactLinkService signed artifact link service + * @param artifactStore artifact store * @param maxInMemorySize maximum in-memory multipart size */ public ConversionController( DocumentConversionService conversionService, TenantAccessService tenantAccessService, ArtifactLinkService artifactLinkService, + com.clearfolio.viewer.artifact.ArtifactStore artifactStore, @Value("${spring.codec.max-in-memory-size:262144B}") DataSize maxInMemorySize) { this.conversionService = conversionService; this.tenantAccessService = tenantAccessService; this.artifactLinkService = artifactLinkService; + this.artifactStore = artifactStore; long bytes = Math.max(1L, maxInMemorySize.toBytes()); this.maxInMemorySizeBytes = bytes > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) bytes; } @@ -160,6 +165,26 @@ public ResponseEntity retryDeadLettered( return ResponseEntity.status(HttpStatus.ACCEPTED).body(SubmitConversionResponse.accepted(jobId)); } + /** + * Deletes a conversion job and its associated generated artifacts. + * + * @param jobId conversion job identifier + * @param headers request headers carrying tenant claims + * @return no content on success + */ + @DeleteMapping("/api/v1/convert/jobs/{jobId}") + public ResponseEntity deleteJob(@PathVariable UUID jobId, @RequestHeader HttpHeaders headers) { + TenantContext tenantContext = tenantAccessService.require(headers, TenantPermissions.JOB_DELETE); + ConversionJob job = conversionService.getJob(jobId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "job not found")); + tenantAccessService.requireSameTenant(tenantContext, job); + + conversionService.deleteJob(jobId); + artifactStore.deletePdf(jobId); + + return ResponseEntity.noContent().build(); + } + /** * Returns viewer bootstrap data once conversion output is ready. * diff --git a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java index ced865a..94d643a 100644 --- a/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/ConversionJobRepository.java @@ -70,4 +70,11 @@ default Optional findByTenantAndContentHash(String tenantId, Stri * @return canonical stored conversion job and whether the candidate was created */ FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate); + + /** + * Deletes a conversion job by identifier. + * + * @param jobId conversion job identifier + */ + void deleteById(UUID jobId); } diff --git a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java index e4168c0..7d7ee63 100644 --- a/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java +++ b/src/main/java/com/clearfolio/viewer/repository/InMemoryConversionJobRepository.java @@ -123,6 +123,17 @@ public List findAll() { return List.copyOf(jobs.values()); } + /** + * {@inheritDoc} + */ + @Override + public void deleteById(UUID jobId) { + ConversionJob removed = jobs.remove(jobId); + if (removed != null && removed.getContentHash() != null && !removed.getContentHash().isBlank()) { + jobsByTenantAndContentHash.remove(contentKey(removed.getTenantId(), removed.getContentHash()), jobId); + } + } + /** * Returns lifecycle events for a conversion job. * diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java index b8787f3..a852c4b 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentConversionService.java @@ -130,6 +130,14 @@ public Optional getJob(UUID jobId) { return repository.findById(jobId); } + /** + * {@inheritDoc} + */ + @Override + public void deleteJob(UUID jobId) { + repository.deleteById(jobId); + } + /** * {@inheritDoc} */ diff --git a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java index ce25045..f22f36a 100644 --- a/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java +++ b/src/main/java/com/clearfolio/viewer/service/DocumentConversionService.java @@ -59,4 +59,11 @@ default UUID submit(MultipartFile file, PolicyOverrideRequest overrideRequest, T * @return retry outcome */ RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId); + + /** + * Deletes a conversion job. + * + * @param jobId conversion job identifier + */ + void deleteJob(UUID jobId); } diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java index 931cb52..86ce983 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerMultipartLimitTest.java @@ -56,6 +56,7 @@ ConversionController conversionController(DocumentConversionService conversionSe conversionService, new TenantAccessService(), new ArtifactLinkService(new InMemoryArtifactStore(), "test-secret"), + new InMemoryArtifactStore(), org.springframework.util.unit.DataSize.ofBytes(2048L) ); } diff --git a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java index 93f65f5..852d32f 100644 --- a/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java +++ b/src/test/java/com/clearfolio/viewer/controller/ConversionControllerTest.java @@ -57,6 +57,7 @@ void setUp() { conversionService, new TenantAccessService(), new ArtifactLinkService(artifactStore, "test-secret"), + artifactStore, DataSize.ofBytes(262_144L) ); webTestClient = WebTestClient.bindToController( @@ -70,6 +71,7 @@ void constructorCapsMaxInMemorySizeAtIntegerMaxValue() throws Exception { conversionService, new TenantAccessService(), new ArtifactLinkService(new InMemoryArtifactStore(), "test-secret"), + new InMemoryArtifactStore(), DataSize.ofBytes((long) Integer.MAX_VALUE + 1) ); Field field = ConversionController.class.getDeclaredField("maxInMemorySizeBytes"); @@ -644,6 +646,64 @@ void viewerAliasRoutesReturnBootstrapWhenReady() { } } + @Test + void deleteJobRequiresDeletePermission() { + UUID jobId = UUID.randomUUID(); + + webTestClient.delete() + .uri("/api/v1/convert/jobs/{jobId}", jobId) + .header(TenantContext.TENANT_ID_HEADER, TenantContext.DEMO_TENANT_ID) + .header(TenantContext.SUBJECT_ID_HEADER, TenantContext.DEMO_SUBJECT_ID) + .header(TenantContext.PERMISSIONS_HEADER, TenantPermissions.JOB_READ) // Missing JOB_DELETE + .exchange() + .expectStatus().isForbidden() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("FORBIDDEN") + .jsonPath("$.message").value(value -> assertContains((String) value, TenantPermissions.JOB_DELETE)); + } + + @Test + void deleteJobReturnsNotFoundWhenJobMissing() { + UUID jobId = UUID.randomUUID(); + when(conversionService.getJob(jobId)).thenReturn(Optional.empty()); + + webTestClient.delete() + .uri("/api/v1/convert/jobs/{jobId}", jobId) + .headers(headers -> addAuth(headers, TenantPermissions.JOB_DELETE)) + .exchange() + .expectStatus().isNotFound() + .expectBody() + .jsonPath("$.errorCode").isEqualTo("NOT_FOUND") + .jsonPath("$.message").isEqualTo("job not found"); + } + + @Test + void deleteJobDeletesJobAndArtifact() { + UUID jobId = UUID.randomUUID(); + ConversionJob job = new ConversionJob( + jobId, + TenantContext.DEMO_TENANT_ID, + TenantContext.DEMO_SUBJECT_ID, + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "abc", + 12L, + 3 + ); + when(conversionService.getJob(jobId)).thenReturn(Optional.of(job)); + artifactStore.putPdf(jobId, new byte[] {1, 2, 3}); + + webTestClient.delete() + .uri("/api/v1/convert/jobs/{jobId}", jobId) + .headers(headers -> addAuth(headers, TenantPermissions.JOB_DELETE)) + .exchange() + .expectStatus().isNoContent() + .expectBody().isEmpty(); + + verify(conversionService).deleteJob(jobId); + assertFalse(artifactStore.getPdf(jobId).isPresent()); + } + private WebTestClient.ResponseSpec submit(String filename, byte[] content) { return submit(filename, content, null, null, null); } diff --git a/src/test/java/com/clearfolio/viewer/repository/ConversionJobRepositoryTest.java b/src/test/java/com/clearfolio/viewer/repository/ConversionJobRepositoryTest.java index 3f736d7..8df2e37 100644 --- a/src/test/java/com/clearfolio/viewer/repository/ConversionJobRepositoryTest.java +++ b/src/test/java/com/clearfolio/viewer/repository/ConversionJobRepositoryTest.java @@ -50,6 +50,10 @@ public FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) { public List findAll() { return List.of(job); } + + @Override + public void deleteById(UUID jobId) { + } }; assertSame(job, repository.findByTenantAndContentHash("tenant-a", "hash").orElseThrow()); diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java index 65bbf19..eff8a14 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultConversionWorkerTest.java @@ -960,5 +960,10 @@ public List findAll() { public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) { return delegate.findOrStoreByContentHash(candidate); } + + @Override + public void deleteById(UUID jobId) { + delegate.deleteById(jobId); + } } } diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java index ca51807..cd4734b 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentConversionServiceTest.java @@ -672,6 +672,11 @@ public java.util.List findAll() { public ConversionJobRepository.FindOrStoreResult findOrStoreByContentHash(ConversionJob candidate) { return finder.apply(candidate); } + + @Override + public void deleteById(UUID jobId) { + throw new UnsupportedOperationException("not used"); + } } private static class RecordingStateStore implements ConversionJobStateStore { diff --git a/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java b/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java index 075df36..1950a18 100644 --- a/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java +++ b/src/test/java/com/clearfolio/viewer/service/ServiceInterfaceDefaultMethodsTest.java @@ -32,6 +32,10 @@ public Optional getJob(UUID jobId) { public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { return RetryDeadLetterResult.NOT_FOUND; } + + @Override + public void deleteJob(UUID jobId) { + } }; UUID actual = service.submit( @@ -67,6 +71,10 @@ public Optional getJob(UUID jobId) { public RetryDeadLetterResult retryDeadLettered(UUID jobId, String operatorId) { return RetryDeadLetterResult.NOT_FOUND; } + + @Override + public void deleteJob(UUID jobId) { + } }; PolicyOverrideRequest overrideRequest = PolicyOverrideRequest.of("true", "token-123", "approver-1");