|
|
@@ -0,0 +1,385 @@
|
|
|
+package cn.yunzhixue.ability.center.examsprint.application.report;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.examsprint.contracts.report.CreateExamSprintReportRequest;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportGenerationStatus;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.contracts.report.ExamSprintReportType;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.contracts.report.OutlookExamSprintReportPayload;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReport;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRepository;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportStorage;
|
|
|
+import cn.yunzhixue.ability.center.kernel.BusinessException;
|
|
|
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
+import jakarta.validation.Validation;
|
|
|
+import jakarta.validation.Validator;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.net.URI;
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Duration;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.concurrent.ConcurrentMap;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
+
|
|
|
+class ExamSprintReportApplicationServiceTest {
|
|
|
+
|
|
|
+ private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-01-02T00:00:00Z"), ZoneOffset.UTC);
|
|
|
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
+ private static final Validator VALIDATOR = Validation.buildDefaultValidatorFactory().getValidator();
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportStoresOutlookTypeAndReturnsReportId() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
+
|
|
|
+ CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ validOutlookPayload());
|
|
|
+
|
|
|
+ var response = service.createReport(request);
|
|
|
+
|
|
|
+ assertThat(response.reportId()).isNotBlank();
|
|
|
+ ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
|
|
|
+ assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.OUTLOOK);
|
|
|
+ assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportRejectsUnsupportedReportTypeBeforeSaving() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ boolean[] dispatched = {false};
|
|
|
+ DefaultExamSprintReportApplicationService service = service(
|
|
|
+ repository,
|
|
|
+ reportId -> dispatched[0] = true,
|
|
|
+ new TestStorage());
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
|
|
|
+ ExamSprintReportType.ACHIEVEMENT,
|
|
|
+ validOutlookPayload())))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
+ .isEqualTo(ErrorCode.REPORT_TYPE_UNSUPPORTED);
|
|
|
+
|
|
|
+ assertThat(repository.storage).isEmpty();
|
|
|
+ assertThat(dispatched[0]).isFalse();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportRejectsInvalidOutlookPayloadBeforeSaving() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ boolean[] dispatched = {false};
|
|
|
+ DefaultExamSprintReportApplicationService service = service(
|
|
|
+ repository,
|
|
|
+ reportId -> dispatched[0] = true,
|
|
|
+ new TestStorage());
|
|
|
+ ObjectNode invalidPayload = validOutlookPayload().deepCopy();
|
|
|
+ ((ObjectNode) invalidPayload.path("reportMetadata")).remove("learnerName");
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ invalidPayload)))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
+ .isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
|
+
|
|
|
+ assertThat(repository.storage).isEmpty();
|
|
|
+ assertThat(dispatched[0]).isFalse();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportReturnsFailedStatusWhenDispatchFails() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ DefaultExamSprintReportApplicationService service = service(
|
|
|
+ repository,
|
|
|
+ reportId -> {
|
|
|
+ throw new IllegalStateException("task-executor-7 rejected by dispatcher unavailable");
|
|
|
+ },
|
|
|
+ new TestStorage());
|
|
|
+
|
|
|
+ CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ validOutlookPayload());
|
|
|
+
|
|
|
+ var response = service.createReport(request);
|
|
|
+
|
|
|
+ assertThat(response.reportId()).isNotBlank();
|
|
|
+ assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
|
|
|
+ ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
|
|
|
+ assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.FAILED);
|
|
|
+ assertThat(saved.failureReason()).isEqualTo("report_generation_dispatch_failed");
|
|
|
+ assertThat(saved.failureReason()).doesNotContain("task-executor-7");
|
|
|
+ assertThat(saved.failureReason()).doesNotContain("dispatcher unavailable");
|
|
|
+ assertThat(repository.countByStatus(ExamSprintReportGenerationStatus.PENDING)).isZero();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportCopiesPayloadBeforeSaving() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
|
|
|
+ ObjectNode payload = validOutlookPayload().deepCopy();
|
|
|
+
|
|
|
+ var response = service.createReport(new CreateExamSprintReportRequest(ExamSprintReportType.OUTLOOK, payload));
|
|
|
+
|
|
|
+ payload.withObject("reportMetadata").put("learnerName", "王同学");
|
|
|
+
|
|
|
+ ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
|
|
|
+ assertThat(saved.payload().path("reportMetadata").path("learnerName").asText()).isEqualTo("李同学");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void getReportReturnsApplicationDownloadUrlForSuccessfulReport() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ ExamSprintReport report = ExamSprintReport.pending(
|
|
|
+ "report-success",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(120),
|
|
|
+ FIXED_CLOCK.instant().plusSeconds(3600))
|
|
|
+ .success(
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(30),
|
|
|
+ "exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf",
|
|
|
+ "exam-sprint-outlook-report-report-success.pdf");
|
|
|
+ repository.save(report);
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
+
|
|
|
+ var response = service.getReport("report-success");
|
|
|
+
|
|
|
+ assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
|
|
|
+ assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
|
|
|
+ assertThat(storage.generatedKeys)
|
|
|
+ .containsExactly("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void downloadReportRejectsExpiredReportBeforeCleanupRuns() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-expired",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(600),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(1)).success(
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(300),
|
|
|
+ "exam-sprint-reports/outlook/report-expired/exam-sprint-outlook-report-report-expired.pdf",
|
|
|
+ "exam-sprint-outlook-report-report-expired.pdf"));
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.downloadReport("report-expired"))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
+ .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cleanupExpiredReportsTreatsExpiresAtEqualsNowAsExpired() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-expired-at-boundary",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(600),
|
|
|
+ FIXED_CLOCK.instant()));
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
|
|
|
+
|
|
|
+ service.cleanupExpiredReports();
|
|
|
+
|
|
|
+ ExamSprintReport saved = repository.findById("report-expired-at-boundary").orElseThrow();
|
|
|
+ assertThat(saved.isExpiredAt(FIXED_CLOCK.instant())).isTrue();
|
|
|
+ assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.EXPIRED);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cleanupExpiredReportsContinuesWhenDeletingOneReportFails() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-delete-fails",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(600),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(1)).success(
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(300),
|
|
|
+ "exam-sprint-reports/outlook/report-delete-fails/first.pdf",
|
|
|
+ "first.pdf"));
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-delete-succeeds",
|
|
|
+ ExamSprintReportType.OUTLOOK,
|
|
|
+ OBJECT_MAPPER.createObjectNode(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(600),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(1)).success(
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(300),
|
|
|
+ "exam-sprint-reports/outlook/report-delete-succeeds/second.pdf",
|
|
|
+ "second.pdf"));
|
|
|
+ storage.failDeleteFor("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
+
|
|
|
+ service.cleanupExpiredReports();
|
|
|
+
|
|
|
+ ExamSprintReport failedDeleteReport = repository.findById("report-delete-fails").orElseThrow();
|
|
|
+ ExamSprintReport successfulDeleteReport = repository.findById("report-delete-succeeds").orElseThrow();
|
|
|
+ assertThat(failedDeleteReport.storageObjectKey())
|
|
|
+ .isEqualTo("exam-sprint-reports/outlook/report-delete-fails/first.pdf");
|
|
|
+ assertThat(successfulDeleteReport.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.EXPIRED);
|
|
|
+ assertThat(successfulDeleteReport.storageObjectKey()).isNull();
|
|
|
+ assertThat(storage.deletedKeys)
|
|
|
+ .contains("exam-sprint-reports/outlook/report-delete-fails/first.pdf")
|
|
|
+ .contains("exam-sprint-reports/outlook/report-delete-succeeds/second.pdf");
|
|
|
+ }
|
|
|
+
|
|
|
+ private DefaultExamSprintReportApplicationService service(
|
|
|
+ TestRepository repository,
|
|
|
+ ExamSprintReportGenerationDispatcher dispatcher,
|
|
|
+ TestStorage storage) {
|
|
|
+ return new DefaultExamSprintReportApplicationService(
|
|
|
+ repository,
|
|
|
+ dispatcher,
|
|
|
+ storage,
|
|
|
+ properties(),
|
|
|
+ FIXED_CLOCK,
|
|
|
+ OBJECT_MAPPER,
|
|
|
+ VALIDATOR);
|
|
|
+ }
|
|
|
+
|
|
|
+ private ExamSprintReportProperties properties() {
|
|
|
+ ExamSprintReportProperties properties = new ExamSprintReportProperties();
|
|
|
+ properties.setRetention(Duration.ofDays(1));
|
|
|
+ properties.setDownloadExpiry(Duration.ofMinutes(15));
|
|
|
+ return properties;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ObjectNode validOutlookPayload() {
|
|
|
+ return (ObjectNode) OBJECT_MAPPER.valueToTree(new OutlookExamSprintReportPayload(
|
|
|
+ new OutlookExamSprintReportPayload.ReportMetadata(
|
|
|
+ "2026 词汇展望报告",
|
|
|
+ "李同学",
|
|
|
+ "雅思 6.5",
|
|
|
+ "2026 春季冲刺",
|
|
|
+ "Ability Bot"),
|
|
|
+ new OutlookExamSprintReportPayload.ReadinessOverview(
|
|
|
+ "词汇能力进入提分窗口,适合围绕考纲和高频场景做集中突破。",
|
|
|
+ "当前阶段:稳态提升",
|
|
|
+ "核心观察:阅读词汇优于写作输出,仍需补齐同义替换。",
|
|
|
+ 78),
|
|
|
+ new OutlookExamSprintReportPayload.SyllabusMasteryProfile(
|
|
|
+ 76,
|
|
|
+ "核心考纲理解较稳,长尾主题词还存在断层。",
|
|
|
+ "先补齐教育、科技和环境主题词,再做套题复盘。",
|
|
|
+ List.of(
|
|
|
+ new OutlookExamSprintReportPayload.DimensionScore("核心考纲", 82),
|
|
|
+ new OutlookExamSprintReportPayload.DimensionScore("场景迁移", 71),
|
|
|
+ new OutlookExamSprintReportPayload.DimensionScore("同义替换", 68))),
|
|
|
+ new OutlookExamSprintReportPayload.VocabularyProfile(
|
|
|
+ 620,
|
|
|
+ 800,
|
|
|
+ 78,
|
|
|
+ "Exam 高频词识别准确,但主动输出不够稳定。",
|
|
|
+ "每次精听后补 5 组同义替换并做口头复述。",
|
|
|
+ List.of("cohesion", "allocate", "feasible")),
|
|
|
+ new OutlookExamSprintReportPayload.VocabularyProfile(
|
|
|
+ 1400,
|
|
|
+ 1800,
|
|
|
+ 77,
|
|
|
+ "Common 高频词覆盖较广,但易混词记忆不牢。",
|
|
|
+ "围绕校园、城市、科技场景做词块复现。",
|
|
|
+ List.of("sustainable", "motivate", "urban")),
|
|
|
+ List.of(
|
|
|
+ new OutlookExamSprintReportPayload.VocabularyFrequencyBand("2k 高频", 86, 90),
|
|
|
+ new OutlookExamSprintReportPayload.VocabularyFrequencyBand("3k 高频", 78, 88),
|
|
|
+ new OutlookExamSprintReportPayload.VocabularyFrequencyBand("学术词", 62, 80)),
|
|
|
+ List.of(
|
|
|
+ new OutlookExamSprintReportPayload.SprintPlanOption(
|
|
|
+ "7 天提分冲刺",
|
|
|
+ "1 周",
|
|
|
+ "推荐",
|
|
|
+ "先保阅读和听力高频正确率",
|
|
|
+ List.of("晨读 30 分钟", "晚间套题复盘", "错词二次听写"),
|
|
|
+ "预计把高频词稳定率拉升到 82%"),
|
|
|
+ new OutlookExamSprintReportPayload.SprintPlanOption(
|
|
|
+ "21 天系统巩固",
|
|
|
+ "3 周",
|
|
|
+ "稳妥",
|
|
|
+ "补齐学术词和写作替换",
|
|
|
+ List.of("主题词块复现", "周测 2 次", "口语素材改写"),
|
|
|
+ "预计把学术词掌握提升到 75%")),
|
|
|
+ new OutlookExamSprintReportPayload.DiagnosticCaseStudy(
|
|
|
+ "教育类阅读题案例",
|
|
|
+ "遇到 unfamiliar policy terms 时定位速度明显下降。",
|
|
|
+ "说明教育政策主题词和近义替换储备不足。",
|
|
|
+ "建立 policy / curriculum / assessment 词网并结合真题复现。",
|
|
|
+ "先按主题建网,再回到题目验证。")));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class TestRepository implements ExamSprintReportRepository {
|
|
|
+ private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public ExamSprintReport save(ExamSprintReport report) {
|
|
|
+ storage.put(report.reportId(), report);
|
|
|
+ return report;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<ExamSprintReport> findById(String reportId) {
|
|
|
+ return Optional.ofNullable(storage.get(reportId));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public List<ExamSprintReport> findExpiredAtOrBefore(Instant instant) {
|
|
|
+ return storage.values().stream().filter(report -> report.isExpiredAt(instant)).toList();
|
|
|
+ }
|
|
|
+
|
|
|
+ long countByStatus(ExamSprintReportGenerationStatus status) {
|
|
|
+ return storage.values().stream().filter(report -> report.generationStatus() == status).count();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class TestStorage implements ExamSprintReportStorage {
|
|
|
+ private final List<String> generatedKeys = new ArrayList<>();
|
|
|
+ private final List<String> deletedKeys = new ArrayList<>();
|
|
|
+ private final List<String> deleteFailures = new ArrayList<>();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public StoredExamSprintReportFile upload(
|
|
|
+ String reportId,
|
|
|
+ ExamSprintReportType reportType,
|
|
|
+ String fileName,
|
|
|
+ byte[] pdfBytes,
|
|
|
+ Instant expiresAt) {
|
|
|
+ return new StoredExamSprintReportFile("blob/" + reportId + "/" + fileName, fileName);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public URI generateDownloadUrl(String storageObjectKey, Duration ttl) {
|
|
|
+ generatedKeys.add(storageObjectKey);
|
|
|
+ return URI.create("/api/exam-sprint/reports/" + storageObjectKey.split("/")[2] + "/download");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public Optional<StoredExamSprintReportContent> download(String storageObjectKey) {
|
|
|
+ return Optional.empty();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void delete(String storageObjectKey) {
|
|
|
+ deletedKeys.add(storageObjectKey);
|
|
|
+ if (deleteFailures.contains(storageObjectKey)) {
|
|
|
+ throw new IllegalStateException("blob client delete failed for " + storageObjectKey);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void failDeleteFor(String storageObjectKey) {
|
|
|
+ deleteFailures.add(storageObjectKey);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|