feat: 숏폼 큐 작업 되돌리기 — 결과를 지우고 다시 대기로

결과가 잘못 나왔을 때 지금은 작업을 통째로 삭제하고 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) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-08-07 10:52:33 +09:00
parent b48ab6494c
commit aba3c5badc
6 changed files with 127 additions and 1 deletions

View File

@ -17,6 +17,11 @@ configurations {
compileOnly { compileOnly {
extendsFrom annotationProcessor extendsFrom annotationProcessor
} }
// Mockito .
// Mockito JVM self-attach ,
// "Could not initialize plugin: MockMaker" .
// (JDK 21+ self-attach )
mockitoAgent
} }
repositories { repositories {
@ -38,6 +43,7 @@ dependencies {
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' 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 'io.hypersistence:hypersistence-utils-hibernate-63:3.9.0'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.2'
@ -49,4 +55,6 @@ dependencies {
tasks.named('test') { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
// self-attach ( mockitoAgent )
jvmArgs "-javaagent:${configurations.mockitoAgent.asPath}"
} }

View File

@ -43,6 +43,11 @@ public class ShortformController {
return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text())); return ApiResponse.ok(shortformService.importResult(request.youtubeUrl(), request.rawText(), request.step1Text()));
} }
@PostMapping("/jobs/{id}/reset")
public ApiResponse<JobDetail> reset(@PathVariable Long id) {
return ApiResponse.ok(shortformService.reset(id));
}
@DeleteMapping("/jobs/{id}") @DeleteMapping("/jobs/{id}")
public ApiResponse<Long> delete(@PathVariable Long id) { public ApiResponse<Long> delete(@PathVariable Long id) {
shortformService.delete(id); shortformService.delete(id);

View File

@ -86,4 +86,17 @@ public class ShortformJob {
this.status = ShortformJobStatus.FAILED; this.status = ShortformJobStatus.FAILED;
this.completedAt = LocalDateTime.now(); 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;
}
} }

View File

@ -65,4 +65,16 @@ public class ShortformService {
public void delete(Long jobId) { public void delete(Long jobId) {
jobRepository.deleteById(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);
}
} }

View File

@ -100,6 +100,17 @@
loadJobs(); 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) { async function toggleJob(id, el) {
if (el.classList.contains('open')) { el.classList.remove('open'); return; } if (el.classList.contains('open')) { el.classList.remove('open'); return; }
const detail = await api('/api/shortform/jobs/' + id); const detail = await api('/api/shortform/jobs/' + id);
@ -166,7 +177,12 @@
<div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div> <div class="u">${esc(j.youtubeUrl)} · 클립 ${j.clipCount}개</div>
</div> </div>
<span class="sf-badge ${j.status}">${j.status}</span> <span class="sf-badge ${j.status}">${j.status}</span>
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)"> ${j.status === 'PENDING' ? '' : `
<button class="btn btn-secondary" onclick="resetJob(${j.id}, event)"
title="결과를 지우고 다시 대기로 — 큐를 돌리면 재실행됩니다">
<i data-lucide="rotate-ccw" style="width:14px;"></i></button>`}
<button class="btn btn-secondary" onclick="deleteJob(${j.id}, event)"
title="작업 삭제">
<i data-lucide="trash-2" style="width:14px;"></i></button> <i data-lucide="trash-2" style="width:14px;"></i></button>
</div> </div>
<div class="sf-clips"></div>`; <div class="sf-clips"></div>`;

View File

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