|
|
@@ -1,10 +1,12 @@
|
|
|
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.AchievementExamSprintReportPayload;
|
|
|
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.ExamSprintReportRenderer;
|
|
|
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;
|
|
|
@@ -14,6 +16,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
import jakarta.validation.Validation;
|
|
|
import jakarta.validation.Validator;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.params.ParameterizedTest;
|
|
|
+import org.junit.jupiter.params.provider.Arguments;
|
|
|
+import org.junit.jupiter.params.provider.MethodSource;
|
|
|
|
|
|
import java.net.URI;
|
|
|
import java.time.Clock;
|
|
|
@@ -25,6 +30,8 @@ import java.util.List;
|
|
|
import java.util.Optional;
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
import java.util.concurrent.ConcurrentMap;
|
|
|
+import java.util.function.Consumer;
|
|
|
+import java.util.stream.Stream;
|
|
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
|
@@ -54,38 +61,67 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- void createReportRejectsUnsupportedReportTypeBeforeSaving() {
|
|
|
+ void createReportStoresAchievementTypeAndReturnsReportId() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ TestStorage storage = new TestStorage();
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
+
|
|
|
+ CreateExamSprintReportRequest request = new CreateExamSprintReportRequest(
|
|
|
+ ExamSprintReportType.ACHIEVEMENT,
|
|
|
+ validAchievementPayload());
|
|
|
+
|
|
|
+ var response = service.createReport(request);
|
|
|
+
|
|
|
+ assertThat(response.reportId()).isNotBlank();
|
|
|
+ ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
|
|
|
+ assertThat(saved.reportType()).isEqualTo(ExamSprintReportType.ACHIEVEMENT);
|
|
|
+ assertThat(saved.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.PENDING);
|
|
|
+ assertThat(saved.payload().path("reportTitle").asText()).isEqualTo("高考英语临考突击学习成果报告");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportRejectsInvalidAchievementPayloadBeforeSaving() {
|
|
|
TestRepository repository = new TestRepository();
|
|
|
boolean[] dispatched = {false};
|
|
|
DefaultExamSprintReportApplicationService service = service(
|
|
|
repository,
|
|
|
reportId -> dispatched[0] = true,
|
|
|
new TestStorage());
|
|
|
+ ObjectNode invalidPayload = validAchievementPayload().deepCopy();
|
|
|
+ invalidPayload.remove("reportTitle");
|
|
|
|
|
|
assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
|
|
|
ExamSprintReportType.ACHIEVEMENT,
|
|
|
- validOutlookPayload())))
|
|
|
+ invalidPayload)))
|
|
|
.isInstanceOf(BusinessException.class)
|
|
|
.extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
- .isEqualTo(ErrorCode.REPORT_TYPE_UNSUPPORTED);
|
|
|
+ .isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
|
|
|
|
assertThat(repository.storage).isEmpty();
|
|
|
assertThat(dispatched[0]).isFalse();
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- void createReportRejectsInvalidOutlookPayloadBeforeSaving() {
|
|
|
+ void createReportRejectsNullOrNonObjectPayloadBeforeSaving() {
|
|
|
+ assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.nullNode());
|
|
|
+ assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.nullNode());
|
|
|
+ assertCreateReportRejectsInvalidPayload(ExamSprintReportType.OUTLOOK, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
|
|
|
+ assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, OBJECT_MAPPER.getNodeFactory().textNode("not-an-object"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createReportRejectsMissingAchievementBeforeValueBeforeSaving() {
|
|
|
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");
|
|
|
+ ObjectNode invalidPayload = validAchievementPayload().deepCopy();
|
|
|
+ invalidPayload.withObject("vocabularyComparison").remove("beforeValue");
|
|
|
|
|
|
assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(
|
|
|
- ExamSprintReportType.OUTLOOK,
|
|
|
+ ExamSprintReportType.ACHIEVEMENT,
|
|
|
invalidPayload)))
|
|
|
.isInstanceOf(BusinessException.class)
|
|
|
.extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
@@ -95,6 +131,17 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
assertThat(dispatched[0]).isFalse();
|
|
|
}
|
|
|
|
|
|
+ @ParameterizedTest(name = "{0}")
|
|
|
+ @MethodSource("invalidAchievementPayloadJsonTypes")
|
|
|
+ void createReportRejectsAchievementPayloadWithInvalidJsonTypes(
|
|
|
+ String caseName,
|
|
|
+ Consumer<ObjectNode> mutatePayload) {
|
|
|
+ ObjectNode invalidPayload = validAchievementPayload().deepCopy();
|
|
|
+ mutatePayload.accept(invalidPayload);
|
|
|
+
|
|
|
+ assertCreateReportRejectsInvalidPayload(ExamSprintReportType.ACHIEVEMENT, invalidPayload);
|
|
|
+ }
|
|
|
+
|
|
|
@Test
|
|
|
void createReportReturnsFailedStatusWhenDispatchFails() {
|
|
|
TestRepository repository = new TestRepository();
|
|
|
@@ -136,19 +183,19 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
- void getReportReturnsApplicationDownloadUrlForSuccessfulReport() {
|
|
|
+ void getReportReturnsDownloadAndPreviewUrlsForSuccessfulReport() {
|
|
|
TestRepository repository = new TestRepository();
|
|
|
TestStorage storage = new TestStorage();
|
|
|
ExamSprintReport report = ExamSprintReport.pending(
|
|
|
"report-success",
|
|
|
- ExamSprintReportType.OUTLOOK,
|
|
|
- OBJECT_MAPPER.createObjectNode(),
|
|
|
+ ExamSprintReportType.ACHIEVEMENT,
|
|
|
+ validAchievementPayload(),
|
|
|
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");
|
|
|
+ "exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf",
|
|
|
+ "exam-sprint-achievement-report-report-success.pdf");
|
|
|
repository.save(report);
|
|
|
DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
|
|
|
|
|
|
@@ -156,8 +203,47 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
|
|
|
assertThat(response.generationStatus()).isEqualTo(ExamSprintReportGenerationStatus.SUCCESS);
|
|
|
assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/report-success/download");
|
|
|
+ assertThat(response.previewHtmlUrl()).isEqualTo("/api/exam-sprint/reports/report-success/preview/html");
|
|
|
assertThat(storage.generatedKeys)
|
|
|
- .containsExactly("exam-sprint-reports/outlook/report-success/exam-sprint-outlook-report-report-success.pdf");
|
|
|
+ .containsExactly("exam-sprint-reports/achievement/report-success/exam-sprint-achievement-report-report-success.pdf");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void previewReportHtmlRendersSavedPayloadForSuccessfulReport() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-preview",
|
|
|
+ ExamSprintReportType.ACHIEVEMENT,
|
|
|
+ validAchievementPayload(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(120),
|
|
|
+ FIXED_CLOCK.instant().plusSeconds(3600))
|
|
|
+ .success(
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(30),
|
|
|
+ "exam-sprint-reports/achievement/report-preview/exam-sprint-achievement-report-report-preview.pdf",
|
|
|
+ "exam-sprint-achievement-report-report-preview.pdf"));
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
|
|
|
+
|
|
|
+ var content = service.previewReportHtml("report-preview");
|
|
|
+
|
|
|
+ assertThat(content.contentType()).isEqualTo("text/html;charset=UTF-8");
|
|
|
+ assertThat(content.html()).contains("preview:高考英语临考突击学习成果报告:2026-01-01T23:59:30Z");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void previewReportHtmlRejectsPendingReport() {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ repository.save(ExamSprintReport.pending(
|
|
|
+ "report-pending",
|
|
|
+ ExamSprintReportType.ACHIEVEMENT,
|
|
|
+ validAchievementPayload(),
|
|
|
+ FIXED_CLOCK.instant().minusSeconds(120),
|
|
|
+ FIXED_CLOCK.instant().plusSeconds(3600)));
|
|
|
+ DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.previewReportHtml("report-pending"))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
+ .isEqualTo(ErrorCode.EXAM_SPRINT_REPORT_DOWNLOAD_UNAVAILABLE);
|
|
|
}
|
|
|
|
|
|
@Test
|
|
|
@@ -248,7 +334,8 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
properties(),
|
|
|
FIXED_CLOCK,
|
|
|
OBJECT_MAPPER,
|
|
|
- VALIDATOR);
|
|
|
+ VALIDATOR,
|
|
|
+ List.of(new PreviewTestRenderer()));
|
|
|
}
|
|
|
|
|
|
private ExamSprintReportProperties properties() {
|
|
|
@@ -314,6 +401,65 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
19)));
|
|
|
}
|
|
|
|
|
|
+ private ObjectNode validAchievementPayload() {
|
|
|
+ return (ObjectNode) OBJECT_MAPPER.valueToTree(new AchievementExamSprintReportPayload(
|
|
|
+ "高考英语临考突击学习成果报告",
|
|
|
+ "2024真题 · 两周专项训练 · 真实提分效果",
|
|
|
+ "恭喜完成两周考前突击专项训练",
|
|
|
+ "基于2024英语真题试卷 · 真实学习效果分析",
|
|
|
+ new AchievementExamSprintReportPayload.SummaryMetrics(
|
|
|
+ "+19",
|
|
|
+ "+4",
|
|
|
+ "1.93%",
|
|
|
+ "0.48倍"),
|
|
|
+ new AchievementExamSprintReportPayload.Comparison(
|
|
|
+ 2328.0,
|
|
|
+ 2347.0,
|
|
|
+ "2328 词",
|
|
|
+ "2347 词",
|
|
|
+ "+19 词"),
|
|
|
+ new AchievementExamSprintReportPayload.Comparison(
|
|
|
+ 650.0,
|
|
|
+ 654.0,
|
|
|
+ "650 个",
|
|
|
+ "654 个",
|
|
|
+ "+4 个"),
|
|
|
+ new AchievementExamSprintReportPayload.ExamUnknownWordsHitStatus(
|
|
|
+ "1.93%",
|
|
|
+ "0.48倍",
|
|
|
+ "207 个",
|
|
|
+ "203 个",
|
|
|
+ "4 个",
|
|
|
+ List.of("number", "bear", "popular", "importance"))));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Stream<Arguments> invalidAchievementPayloadJsonTypes() {
|
|
|
+ return Stream.of(
|
|
|
+ Arguments.of("reportTitle boolean is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", true)),
|
|
|
+ Arguments.of("reportTitle number is rejected", (Consumer<ObjectNode>) payload -> payload.put("reportTitle", 123)),
|
|
|
+ Arguments.of("vocabularyComparison.beforeValue string is rejected",
|
|
|
+ (Consumer<ObjectNode>) payload -> payload.withObject("vocabularyComparison").put("beforeValue", "2328")),
|
|
|
+ Arguments.of("examUnknownWordsHitStatus.hitWords number element is rejected",
|
|
|
+ (Consumer<ObjectNode>) payload -> payload.withObject("examUnknownWordsHitStatus").putArray("hitWords").add(123)));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertCreateReportRejectsInvalidPayload(ExamSprintReportType reportType, com.fasterxml.jackson.databind.JsonNode payload) {
|
|
|
+ TestRepository repository = new TestRepository();
|
|
|
+ boolean[] dispatched = {false};
|
|
|
+ DefaultExamSprintReportApplicationService service = service(
|
|
|
+ repository,
|
|
|
+ reportId -> dispatched[0] = true,
|
|
|
+ new TestStorage());
|
|
|
+
|
|
|
+ assertThatThrownBy(() -> service.createReport(new CreateExamSprintReportRequest(reportType, payload)))
|
|
|
+ .isInstanceOf(BusinessException.class)
|
|
|
+ .extracting(exception -> ((BusinessException) exception).getErrorCode())
|
|
|
+ .isEqualTo(ErrorCode.VALIDATION_ERROR);
|
|
|
+
|
|
|
+ assertThat(repository.storage).isEmpty();
|
|
|
+ assertThat(dispatched[0]).isFalse();
|
|
|
+ }
|
|
|
+
|
|
|
private static class TestRepository implements ExamSprintReportRepository {
|
|
|
private final ConcurrentMap<String, ExamSprintReport> storage = new ConcurrentHashMap<>();
|
|
|
|
|
|
@@ -376,4 +522,17 @@ class ExamSprintReportApplicationServiceTest {
|
|
|
deleteFailures.add(storageObjectKey);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ private static class PreviewTestRenderer implements ExamSprintReportRenderer {
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public boolean supports(ExamSprintReportType reportType) {
|
|
|
+ return reportType == ExamSprintReportType.OUTLOOK || reportType == ExamSprintReportType.ACHIEVEMENT;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String render(com.fasterxml.jackson.databind.JsonNode payload, Instant generatedAt) {
|
|
|
+ return "<html><body>preview:" + payload.path("reportTitle").asText() + ":" + generatedAt + "</body></html>";
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|