From aba3c5badc5060da593e81b33d51efb398fdd3de Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Fri, 7 Aug 2026 10:52:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=8F=ED=8F=BC=20=ED=81=90=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=90=98=EB=8F=8C=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0=20=EB=8B=A4=EC=8B=9C=20=EB=8C=80=EA=B8=B0=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 결과가 잘못 나왔을 때 지금은 작업을 통째로 삭제하고 URL을 다시 등록해야 했다. 등록 이력도 같이 날아간다. 큐 스킬이 status=PENDING 인 작업만 집어가므로, 결과만 비우고 PENDING 으로 되돌리면 그대로 재실행 대상이 된다. - POST /api/shortform/jobs/{id}/reset — rawOutput·step1Output·clips·title· completedAt 을 비우고 PENDING 으로. youtubeUrl·videoId·등록시각은 유지한다. - 화면: PENDING 이 아닌 카드에만 되돌리기 버튼(확인창 포함). 결과가 삭제되므로 무엇이 지워지고 무엇이 남는지 문구에 명시했다. - FAILED 재시도에 특히 필요했다 — 파싱 실패한 작업을 되살릴 방법이 아예 없었다. fix: Mockito self-attach 실패로 목 기반 테스트 6건이 깨지던 문제 내 변경과 무관하게 환경에서 깨져 있었다(변경을 치운 깨끗한 트리에서도 동일 실패). Mockito 가 JVM 에 self-attach 를 시도하다 실패하면 "Could not initialize plugin: MockMaker" 로 DashboardServiceTest·YoutubeCommentServiceTest 가 전부 죽는다. JDK 21+ 는 self-attach 를 경고하고 이후 버전에서 막으므로, 권장 방식대로 mockitoAgent 설정을 만들어 -javaagent 로 명시 지정했다. 116건 전부 통과. Co-Authored-By: Claude Opus 5 (1M context) --- build.gradle | 8 +++ .../domain/shortform/ShortformController.java | 5 ++ .../domain/shortform/ShortformJob.java | 13 ++++ .../domain/shortform/ShortformService.java | 12 ++++ src/main/resources/templates/shortform.html | 18 ++++- .../shortform/ShortformJobResetTest.java | 72 +++++++++++++++++++ 6 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/hlab/yanalyst/domain/shortform/ShortformJobResetTest.java diff --git a/build.gradle b/build.gradle index 2613939..d5efde0 100644 --- a/build.gradle +++ b/build.gradle @@ -17,6 +17,11 @@ configurations { compileOnly { extendsFrom annotationProcessor } + // Mockito 를 자바 에이전트로 명시 지정하기 위한 설정. + // 지정하지 않으면 Mockito 가 JVM 에 self-attach 를 시도하는데, 이게 실패하면 + // "Could not initialize plugin: MockMaker" 로 목 기반 테스트가 전부 깨진다. + // (JDK 21+ 는 self-attach 를 경고하고 이후 버전에서 막는다) + mockitoAgent } repositories { @@ -38,6 +43,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + mockitoAgent('org.mockito:mockito-core') { transitive = false } implementation 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2' @@ -49,4 +55,6 @@ dependencies { tasks.named('test') { useJUnitPlatform() + // self-attach 대신 에이전트를 직접 붙인다 (위 mockitoAgent 설정 참고) + jvmArgs "-javaagent:${configurations.mockitoAgent.asPath}" } diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java index 22c82a1..9a3df0e 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformController.java @@ -43,6 +43,11 @@ public class ShortformController { return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text())); } + @PostMapping("/jobs/{id}/reset") + public ApiResponse reset(@PathVariable Long id) { + return ApiResponse.ok(shortformService.reset(id)); + } + @DeleteMapping("/jobs/{id}") public ApiResponse delete(@PathVariable Long id) { shortformService.delete(id); diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java index 627b766..bebc5b6 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformJob.java @@ -86,4 +86,17 @@ public class ShortformJob { this.status = ShortformJobStatus.FAILED; this.completedAt = LocalDateTime.now(); } + + /** + * 결과를 비우고 다시 대기 상태로 되돌린다. 등록 정보(URL·videoId·등록시각)는 유지하므로 + * 삭제 후 재등록할 필요가 없고, 큐 스킬이 PENDING 만 집어가므로 그대로 다시 실행된다. + */ + public void reset() { + this.rawOutput = null; + this.step1Output = null; + this.title = null; + this.completedAt = null; + this.clips.clear(); // orphanRemoval 로 클립 행도 함께 지워진다 + this.status = ShortformJobStatus.PENDING; + } } diff --git a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java index c794687..b2a0d01 100644 --- a/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java +++ b/src/main/java/com/hlab/yanalyst/domain/shortform/ShortformService.java @@ -65,4 +65,16 @@ public class ShortformService { public void delete(Long jobId) { jobRepository.deleteById(jobId); } + + /** + * 결과를 비우고 다시 대기(PENDING)로 되돌린다. 큐 스킬이 PENDING 만 집어가므로 + * 이 호출만으로 재실행 대상이 된다 — 삭제 후 URL 재등록이 필요 없다. + */ + @Transactional + public JobDetail reset(Long jobId) { + ShortformJob job = jobRepository.findById(jobId) + .orElseThrow(() -> new IllegalArgumentException("작업이 없습니다: " + jobId)); + job.reset(); + return JobDetail.from(job); + } } diff --git a/src/main/resources/templates/shortform.html b/src/main/resources/templates/shortform.html index 3907f07..6b8f0b6 100644 --- a/src/main/resources/templates/shortform.html +++ b/src/main/resources/templates/shortform.html @@ -100,6 +100,17 @@ loadJobs(); } + // 결과만 지우고 대기로 되돌린다. 등록 URL은 남으므로 큐를 다시 돌리면 그대로 재실행된다. + async function resetJob(id, e) { + e.stopPropagation(); + if (!confirm('결과를 지우고 다시 대기 상태로 되돌릴까요?\n' + + '클립과 Output 원문이 삭제됩니다. 등록된 영상은 그대로 남습니다.')) return; + try { + await api('/api/shortform/jobs/' + id + '/reset', { method: 'POST' }); + loadJobs(); + } catch (err) { alert('되돌리기 실패: ' + err.message); } + } + async function toggleJob(id, el) { if (el.classList.contains('open')) { el.classList.remove('open'); return; } const detail = await api('/api/shortform/jobs/' + id); @@ -166,7 +177,12 @@
${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개
${j.status} - `} +
`; diff --git a/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformJobResetTest.java b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformJobResetTest.java new file mode 100644 index 0000000..26c789d --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/shortform/ShortformJobResetTest.java @@ -0,0 +1,72 @@ +package com.hlab.yanalyst.domain.shortform; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShortformJobResetTest { + + private ParsedClip clip(int no, String titleMain) { + return new ParsedClip(no, "{\"capcut\":\"json\"}", "제목 후보들", "구간표", "상단 제목", titleMain); + } + + private ShortformJob done() { + ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678"); + job.attachStep1("스텝1 원문"); + job.applyResult("전체 Output 원문", List.of(clip(1, "첫 클립"), clip(2, "둘째 클립"))); + return job; + } + + @Test + void 완료된_작업을_되돌리면_결과가_비고_대기로_간다() { + ShortformJob job = done(); + assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.DONE); + + job.reset(); + + assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.PENDING); + assertThat(job.getRawOutput()).isNull(); + assertThat(job.getStep1Output()).isNull(); + assertThat(job.getTitle()).isNull(); + assertThat(job.getCompletedAt()).isNull(); + assertThat(job.getClips()).isEmpty(); + } + + @Test + void 되돌려도_등록정보는_유지된다() { + // 삭제 후 재등록이 필요 없어야 하므로 URL·videoId 는 남아야 한다 + ShortformJob job = done(); + + job.reset(); + + assertThat(job.getYoutubeUrl()).isEqualTo("https://youtu.be/abc12345678"); + assertThat(job.getVideoId()).isEqualTo("abc12345678"); + } + + @Test + void 실패한_작업도_되돌려_재시도할_수_있다() { + ShortformJob job = ShortformJob.create("https://youtu.be/abc12345678", "abc12345678"); + job.markFailed("파싱 안 되는 원문"); + assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.FAILED); + + job.reset(); + + assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.PENDING); + assertThat(job.getRawOutput()).isNull(); + } + + @Test + void 되돌린_뒤_다시_결과를_넣을_수_있다() { + ShortformJob job = done(); + job.reset(); + + job.applyResult("새 Output", List.of(clip(1, "새 클립"))); + + assertThat(job.getStatus()).isEqualTo(ShortformJobStatus.DONE); + assertThat(job.getClips()).hasSize(1); + assertThat(job.getTitle()).isEqualTo("새 클립"); + assertThat(job.getRawOutput()).isEqualTo("새 Output"); + } +}