|
|
@@ -0,0 +1,249 @@
|
|
|
+package cn.yunzhixue.ability.center.examsprint.application.report;
|
|
|
+
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.AchievementReportContent;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportPdfGenerator;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ExamSprintReportRenderer;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportContent;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.ReportType;
|
|
|
+import cn.yunzhixue.ability.center.examsprint.domain.report.UnmodeledReportContent;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.springframework.boot.test.system.CapturedOutput;
|
|
|
+import org.springframework.boot.test.system.OutputCaptureExtension;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.time.Clock;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.List;
|
|
|
+import java.util.concurrent.Executor;
|
|
|
+
|
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
|
+
|
|
|
+@ExtendWith(OutputCaptureExtension.class)
|
|
|
+class ExamSprintReportWarmupRunnerTest {
|
|
|
+
|
|
|
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
|
|
+ private static final Clock FIXED_CLOCK = Clock.fixed(Instant.parse("2026-05-07T08:00:00Z"), ZoneOffset.UTC);
|
|
|
+
|
|
|
+ /** 覆盖启动事件异步提交场景,当应用 ready 后,应只提交后台任务而不在线程内同步执行预热。 */
|
|
|
+ @Test
|
|
|
+ void onApplicationReadySubmitsWarmupJobWithoutRunningInline() {
|
|
|
+ List<String> events = new ArrayList<>();
|
|
|
+ RecordingExecutor executor = new RecordingExecutor();
|
|
|
+ RecordingRenderer outlookRenderer = new RecordingRenderer(ReportType.OUTLOOK, "outlook-html", events);
|
|
|
+ RecordingRenderer achievementRenderer = new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events);
|
|
|
+ RecordingPdfGenerator pdfGenerator = new RecordingPdfGenerator(events);
|
|
|
+ ExamSprintReportWarmupRunner runner = runner(
|
|
|
+ List.of(outlookRenderer, achievementRenderer),
|
|
|
+ pdfGenerator,
|
|
|
+ executor);
|
|
|
+
|
|
|
+ runner.onApplicationReady();
|
|
|
+
|
|
|
+ assertThat(executor.commands).hasSize(1);
|
|
|
+ assertThat(events).isEmpty();
|
|
|
+
|
|
|
+ executor.runOnlyCommand();
|
|
|
+
|
|
|
+ assertThat(events).containsExactly(
|
|
|
+ "render:OUTLOOK",
|
|
|
+ "pdf:outlook-html",
|
|
|
+ "render:ACHIEVEMENT",
|
|
|
+ "pdf:achievement-html");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 覆盖 warmup 异步提交失败场景,当 executor 拒绝任务时,应记录安全日志且不向 ApplicationReadyEvent 抛出异常。 */
|
|
|
+ @Test
|
|
|
+ void onApplicationReadyLogsSafeFailureWhenExecutorRejectsTask(CapturedOutput output) {
|
|
|
+ List<String> events = new ArrayList<>();
|
|
|
+ Executor rejectingExecutor = command -> {
|
|
|
+ throw new java.util.concurrent.RejectedExecutionException("SENSITIVE_EXECUTOR_REJECTION_DO_NOT_LOG");
|
|
|
+ };
|
|
|
+ ExamSprintReportWarmupRunner runner = runner(
|
|
|
+ List.of(
|
|
|
+ new RecordingRenderer(ReportType.OUTLOOK, "outlook-html", events),
|
|
|
+ new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events)),
|
|
|
+ new RecordingPdfGenerator(events),
|
|
|
+ rejectingExecutor);
|
|
|
+
|
|
|
+ runner.onApplicationReady();
|
|
|
+
|
|
|
+ assertThat(events).isEmpty();
|
|
|
+ assertThat(output.getAll())
|
|
|
+ .contains("exam_sprint_report_warmup_failed")
|
|
|
+ .contains("reportType=ALL")
|
|
|
+ .contains("stage=async_submit")
|
|
|
+ .contains("exceptionType=RejectedExecutionException")
|
|
|
+ .contains("failureReason=RejectedExecutionException")
|
|
|
+ .doesNotContain("SENSITIVE_EXECUTOR_REJECTION_DO_NOT_LOG")
|
|
|
+ .doesNotContain("StudentWordsLatest")
|
|
|
+ .doesNotContain("TestPaperImprovedWords");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 覆盖串行预热主流程,当两个报告资源有效时,应按 OUTLOOK 到 ACHIEVEMENT 顺序渲染并生成 PDF。 */
|
|
|
+ @Test
|
|
|
+ void warmUpReportsRendersOutlookBeforeAchievementAndGeneratesPdf(CapturedOutput output) {
|
|
|
+ List<String> events = new ArrayList<>();
|
|
|
+ RecordingRenderer outlookRenderer = new RecordingRenderer(ReportType.OUTLOOK, "outlook-html", events);
|
|
|
+ RecordingRenderer achievementRenderer = new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events);
|
|
|
+ RecordingPdfGenerator pdfGenerator = new RecordingPdfGenerator(events);
|
|
|
+ ExamSprintReportWarmupRunner runner = runner(
|
|
|
+ List.of(outlookRenderer, achievementRenderer),
|
|
|
+ pdfGenerator,
|
|
|
+ Runnable::run);
|
|
|
+
|
|
|
+ runner.warmUpReports();
|
|
|
+
|
|
|
+ assertThat(events).containsExactly(
|
|
|
+ "render:OUTLOOK",
|
|
|
+ "pdf:outlook-html",
|
|
|
+ "render:ACHIEVEMENT",
|
|
|
+ "pdf:achievement-html");
|
|
|
+ assertThat(outlookRenderer.generatedAt).containsExactly(FIXED_CLOCK.instant());
|
|
|
+ assertThat(achievementRenderer.generatedAt).containsExactly(FIXED_CLOCK.instant());
|
|
|
+
|
|
|
+ assertThat(outlookRenderer.renderedContents).singleElement().isInstanceOf(UnmodeledReportContent.class);
|
|
|
+ UnmodeledReportContent outlookContent = (UnmodeledReportContent) outlookRenderer.renderedContents.get(0);
|
|
|
+ assertThat(outlookContent.reportType()).isEqualTo(ReportType.OUTLOOK);
|
|
|
+ assertThat(((JsonNode) outlookContent.source()).path("StudentName").asText()).isEqualTo("20260318测试");
|
|
|
+
|
|
|
+ assertThat(achievementRenderer.renderedContents).singleElement().isInstanceOf(AchievementReportContent.class);
|
|
|
+ AchievementReportContent achievementContent = (AchievementReportContent) achievementRenderer.renderedContents.get(0);
|
|
|
+ assertThat(achievementContent.studentName()).isEqualTo("20260318测试");
|
|
|
+ assertThat(achievementContent.reportTitle()).isEqualTo("初中英语临考突击学习成果报告");
|
|
|
+
|
|
|
+ assertThat(pdfGenerator.htmlInputs).containsExactly("outlook-html", "achievement-html");
|
|
|
+ assertThat(output.getAll())
|
|
|
+ .contains("exam_sprint_report_warmup_started")
|
|
|
+ .contains("exam_sprint_report_warmup_succeeded")
|
|
|
+ .contains("reportType=OUTLOOK")
|
|
|
+ .contains("reportType=ACHIEVEMENT")
|
|
|
+ .contains("durationMs=")
|
|
|
+ .contains("renderDurationMs=")
|
|
|
+ .contains("pdfDurationMs=")
|
|
|
+ .contains("htmlLength=")
|
|
|
+ .contains("pdfByteLength=");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 覆盖单报告失败隔离场景,当 OUTLOOK 渲染失败时,应记录安全失败日志且继续预热 ACHIEVEMENT。 */
|
|
|
+ @Test
|
|
|
+ void warmUpReportsLogsFailureWithoutPayloadOrSensitiveMessageAndContinues(CapturedOutput output) {
|
|
|
+ List<String> events = new ArrayList<>();
|
|
|
+ FailingRenderer failingOutlookRenderer = new FailingRenderer(ReportType.OUTLOOK, events);
|
|
|
+ RecordingRenderer achievementRenderer = new RecordingRenderer(ReportType.ACHIEVEMENT, "achievement-html", events);
|
|
|
+ RecordingPdfGenerator pdfGenerator = new RecordingPdfGenerator(events);
|
|
|
+ ExamSprintReportWarmupRunner runner = runner(
|
|
|
+ List.of(failingOutlookRenderer, achievementRenderer),
|
|
|
+ pdfGenerator,
|
|
|
+ Runnable::run);
|
|
|
+
|
|
|
+ runner.warmUpReports();
|
|
|
+
|
|
|
+ assertThat(events).containsExactly(
|
|
|
+ "render:OUTLOOK",
|
|
|
+ "render:ACHIEVEMENT",
|
|
|
+ "pdf:achievement-html");
|
|
|
+ assertThat(achievementRenderer.renderedContents).singleElement().isInstanceOf(AchievementReportContent.class);
|
|
|
+ assertThat(pdfGenerator.htmlInputs).containsExactly("achievement-html");
|
|
|
+ assertThat(output.getAll())
|
|
|
+ .contains("exam_sprint_report_warmup_failed")
|
|
|
+ .contains("reportType=OUTLOOK")
|
|
|
+ .contains("stage=render_html")
|
|
|
+ .contains("exceptionType=IllegalStateException")
|
|
|
+ .contains("failureReason=IllegalStateException")
|
|
|
+ .contains("exam_sprint_report_warmup_succeeded")
|
|
|
+ .contains("reportType=ACHIEVEMENT")
|
|
|
+ .doesNotContain("SENSITIVE_WARMUP_FAILURE_DO_NOT_LOG")
|
|
|
+ .doesNotContain("StudentWordsLatest")
|
|
|
+ .doesNotContain("TestPaperImprovedWords");
|
|
|
+ }
|
|
|
+
|
|
|
+ private ExamSprintReportWarmupRunner runner(
|
|
|
+ List<ExamSprintReportRenderer> renderers,
|
|
|
+ ExamSprintReportPdfGenerator pdfGenerator,
|
|
|
+ Executor executor) {
|
|
|
+ return new ExamSprintReportWarmupRunner(renderers, pdfGenerator, OBJECT_MAPPER, FIXED_CLOCK, executor);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class RecordingExecutor implements Executor {
|
|
|
+ private final List<Runnable> commands = new ArrayList<>();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void execute(Runnable command) {
|
|
|
+ commands.add(command);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void runOnlyCommand() {
|
|
|
+ assertThat(commands).hasSize(1);
|
|
|
+ commands.get(0).run();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class RecordingRenderer implements ExamSprintReportRenderer {
|
|
|
+ private final ReportType reportType;
|
|
|
+ private final String html;
|
|
|
+ private final List<String> events;
|
|
|
+ private final List<ReportContent> renderedContents = new ArrayList<>();
|
|
|
+ private final List<Instant> generatedAt = new ArrayList<>();
|
|
|
+
|
|
|
+ private RecordingRenderer(ReportType reportType, String html, List<String> events) {
|
|
|
+ this.reportType = reportType;
|
|
|
+ this.html = html;
|
|
|
+ this.events = events;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public boolean supports(ReportType reportType) {
|
|
|
+ return this.reportType == reportType;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String render(ReportContent content, Instant generatedAt) {
|
|
|
+ events.add("render:" + reportType);
|
|
|
+ renderedContents.add(content);
|
|
|
+ this.generatedAt.add(generatedAt);
|
|
|
+ return html;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class FailingRenderer implements ExamSprintReportRenderer {
|
|
|
+ private final ReportType reportType;
|
|
|
+ private final List<String> events;
|
|
|
+
|
|
|
+ private FailingRenderer(ReportType reportType, List<String> events) {
|
|
|
+ this.reportType = reportType;
|
|
|
+ this.events = events;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public boolean supports(ReportType reportType) {
|
|
|
+ return this.reportType == reportType;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public String render(ReportContent content, Instant generatedAt) {
|
|
|
+ events.add("render:" + reportType);
|
|
|
+ throw new IllegalStateException("SENSITIVE_WARMUP_FAILURE_DO_NOT_LOG");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class RecordingPdfGenerator implements ExamSprintReportPdfGenerator {
|
|
|
+ private final List<String> events;
|
|
|
+ private final List<String> htmlInputs = new ArrayList<>();
|
|
|
+
|
|
|
+ private RecordingPdfGenerator(List<String> events) {
|
|
|
+ this.events = events;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public byte[] generate(String htmlContent) {
|
|
|
+ events.add("pdf:" + htmlContent);
|
|
|
+ htmlInputs.add(htmlContent);
|
|
|
+ return ("pdf:" + htmlContent).getBytes(StandardCharsets.UTF_8);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|