Prechádzať zdrojové kódy

fix(exam-sprint): 兼容展望报告 StudentName 文件名

金逸霄 2 týždňov pred
rodič
commit
ef1f11292e

+ 49 - 3
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/DefaultExamSprintReportApplicationService.java

@@ -71,7 +71,8 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     @Override
     public CreateExamSprintReportResponse createOutlookReport(JsonNode payload) {
         validateOutlookPayload(payload);
-        return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
+        JsonNode normalizedPayload = normalizeOutlookPayload(payload);
+        return submitReportGeneration(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, normalizedPayload));
     }
 
     @Override
@@ -83,7 +84,8 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     @Override
     public CreateExamSprintReportWithUrlResponse createOutlookReportSync(JsonNode payload) {
         validateOutlookPayload(payload);
-        return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, payload.deepCopy()));
+        JsonNode normalizedPayload = normalizeOutlookPayload(payload);
+        return submitReportGenerationSync(ReportType.OUTLOOK, new UnmodeledReportContent(ReportType.OUTLOOK, normalizedPayload));
     }
 
     @Override
@@ -383,17 +385,61 @@ public class DefaultExamSprintReportApplicationService implements ExamSprintRepo
     }
 
     private void validateOutlookPayload(JsonNode payload) {
+        validateOutlookStudentNameFields(payload);
         OutlookExamSprintReportPayload reportPayload = readPayload(outlookPayloadForValidation(payload), OutlookExamSprintReportPayload.class);
         validatePayload(reportPayload);
     }
 
+    private void validateOutlookStudentNameFields(JsonNode payload) {
+        requireObjectPayload(payload);
+        if (resolveOutlookStudentName(payload).isPresent()) {
+            return;
+        }
+        if (hasNonNullNonTextualField(payload, "StudentName") || hasNonNullNonTextualField(payload, "studentName")) {
+            throw new BusinessException(ErrorCode.VALIDATION_ERROR);
+        }
+    }
+
+    private JsonNode normalizeOutlookPayload(JsonNode payload) {
+        requireObjectPayload(payload);
+        ObjectNode normalizedPayload = ((ObjectNode) payload).deepCopy();
+        resolveOutlookStudentName(normalizedPayload).ifPresent(studentName -> {
+            normalizedPayload.put("StudentName", studentName);
+            normalizedPayload.put("studentName", studentName);
+        });
+        return normalizedPayload;
+    }
+
+    private Optional<String> resolveOutlookStudentName(JsonNode payload) {
+        Optional<String> canonicalStudentName = textualNonBlankValue(payload, "StudentName");
+        return canonicalStudentName.isPresent()
+                ? canonicalStudentName
+                : textualNonBlankValue(payload, "studentName");
+    }
+
+    private Optional<String> textualNonBlankValue(JsonNode payload, String fieldName) {
+        JsonNode field = payload.get(fieldName);
+        if (field == null || !field.isTextual()) {
+            return Optional.empty();
+        }
+        String trimmedValue = field.asText().trim();
+        return trimmedValue.isBlank() ? Optional.empty() : Optional.of(trimmedValue);
+    }
+
+    private boolean hasNonNullNonTextualField(JsonNode payload, String fieldName) {
+        JsonNode field = payload.get(fieldName);
+        return field != null && !field.isNull() && !field.isTextual();
+    }
+
     private JsonNode outlookPayloadForValidation(JsonNode payload) {
         requireObjectPayload(payload);
         if (!payload.has("StudentName") || !payload.has("studentName")) {
             return payload;
         }
 
-        ObjectNode validationPayload = payload.deepCopy();
+        ObjectNode validationPayload = ((ObjectNode) payload).deepCopy();
+        resolveOutlookStudentName(validationPayload)
+                .ifPresent(studentName -> validationPayload.put("StudentName", studentName));
         validationPayload.remove("studentName");
         return validationPayload;
     }

+ 6 - 0
abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationPipeline.java

@@ -193,6 +193,12 @@ public class ExamSprintReportGenerationPipeline {
     private String studentNameFromContent(ReportContent content) {
         if (content instanceof UnmodeledReportContent unmodeledReportContent
                 && unmodeledReportContent.source() instanceof JsonNode payload) {
+            JsonNode canonicalStudentName = payload.get("StudentName");
+            if (canonicalStudentName != null
+                    && canonicalStudentName.isTextual()
+                    && !canonicalStudentName.asText().trim().isBlank()) {
+                return canonicalStudentName.asText();
+            }
             JsonNode studentName = payload.get("studentName");
             return studentName != null && studentName.isTextual() ? studentName.asText() : null;
         }

+ 126 - 9
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportApplicationServiceTest.java

@@ -71,7 +71,7 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain(removedHtmlPreviewMethodName());
     }
 
-    /** 覆盖创建展望报告的上游词汇报文场景,当提交有效 payload 时,应保存 OUTLOOK 报告并保留 StudentName 和 StageVocabulary。 */
+    /** 覆盖创建展望报告的上游词汇报文场景,当提交有效 payload 时,应保存 OUTLOOK 报告并归一化 StudentName/studentName。 */
     @Test
     void createOutlookReportStoresOutlookTypeAndReturnsReportId() {
         TestRepository repository = new TestRepository();
@@ -87,6 +87,7 @@ class ExamSprintReportApplicationServiceTest {
         UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
         JsonNode savedPayload = (JsonNode) content.source();
         assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
         assertThat(savedPayload.path("StageVocabulary").asInt()).isEqualTo(10);
     }
 
@@ -107,6 +108,70 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(dispatchedReportIds).containsExactly(response.reportId());
     }
 
+    /** 覆盖 mixed-field 展望报文场景,当 StudentName 为空白或 null 且 studentName 有效时,应允许通过并归一化到 lowercase 值。 */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("mixedOutlookPayloadsWithInvalidCanonicalStudentName")
+    void createOutlookReportNormalizesLowercaseStudentNameWhenCanonicalStudentNameIsInvalid(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) throws Exception {
+        TestRepository repository = new TestRepository();
+        TestStorage storage = new TestStorage();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, storage);
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        payload.put("studentName", "冯亿豪");
+        mutatePayload.accept(payload);
+
+        var response = service.createOutlookReport(payload);
+
+        ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
+        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) saved.content()).source();
+        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("冯亿豪");
+        assertThat(savedPayload.path("studentName").asText()).isEqualTo("冯亿豪");
+    }
+
+    /** 覆盖 canonical StudentName 类型边界场景,当 StudentName 为非 textual 且无有效 lowercase fallback 时,应在保存前校验失败。 */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("invalidCanonicalStudentNameJsonTypes")
+    void createOutlookReportRejectsNonTextualCanonicalStudentNameWithoutValidLowercaseFallback(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) throws Exception {
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        mutatePayload.accept(payload);
+
+        assertCreateOutlookReportRejectsInvalidPayload(payload);
+    }
+
+    /** 覆盖 lowercase studentName 类型边界场景,当它是唯一可用来源但为非 textual 时,应在保存前校验失败。 */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("invalidLowercaseStudentNameJsonTypes")
+    void createOutlookReportRejectsNonTextualLowercaseStudentNameWhenItIsOnlyAvailableSource(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) throws Exception {
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        payload.putNull("StudentName");
+        mutatePayload.accept(payload);
+
+        assertCreateOutlookReportRejectsInvalidPayload(payload);
+    }
+
+    /** 覆盖 canonical 优先场景,当 StudentName 有效而 lowercase studentName 为非 textual 时,应继续使用 canonical 值。 */
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("invalidLowercaseStudentNameJsonTypes")
+    void createOutlookReportUsesCanonicalStudentNameWhenLowercaseStudentNameIsNonTextual(
+            String caseName,
+            Consumer<ObjectNode> mutatePayload) throws Exception {
+        TestRepository repository = new TestRepository();
+        DefaultExamSprintReportApplicationService service = service(repository, reportId -> { }, new TestStorage());
+        ObjectNode payload = callerVocabularyPayload().deepCopy();
+        mutatePayload.accept(payload);
+
+        var response = service.createOutlookReport(payload);
+
+        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) repository.findById(response.reportId()).orElseThrow().content()).source();
+        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
+    }
+
     /** 覆盖非上游词汇展望报文被移除的场景,当只提交非官方结构时,应在保存前校验失败。 */
     @Test
     void createOutlookReportRejectsNonContractPayloadBeforeSaving() throws Exception {
@@ -171,7 +236,7 @@ class ExamSprintReportApplicationServiceTest {
                 .containsExactly("number", "bear", "popular", "importance");
     }
 
-    /** 覆盖同步创建展望报告场景,当有效上游词汇报文生成成功时,应上传 PDF 并返回下载地址。 */
+    /** 覆盖同步创建展望报告场景,当 StudentName 与 studentName 冲突时,应以 StudentName 为准生成文件名并归一化保存。 */
     @Test
     void createOutlookReportSyncGeneratesUploadAndReturnsDownloadUrl() {
         TestRepository repository = new TestRepository();
@@ -195,13 +260,16 @@ class ExamSprintReportApplicationServiceTest {
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        String expectedFileName = "冯亿豪-临考词汇突击潜力展望报告-20260102080000.pdf";
+        String expectedFileName = "20260318测试-临考词汇突击潜力展望报告-20260102080000.pdf";
         assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
         assertThat(saved.fileName()).isEqualTo(expectedFileName);
         assertThat(storage.generatedKeys).containsExactly(expectedFileName);
+        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) saved.content()).source();
+        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
     }
 
-    /** 覆盖同步创建展望报告的调用方词汇报文场景,当 lowercase studentName 缺失时,应回退到 reportId 生成文件名。 */
+    /** 覆盖同步创建展望报告的调用方词汇报文场景,当只有 StudentName 时,应直接使用 StudentName 生成文件名。 */
     @Test
     void createOutlookReportSyncAcceptsCallerVocabularyPayloadAndReturnsDownloadUrl() throws Exception {
         TestRepository repository = new TestRepository();
@@ -220,7 +288,7 @@ class ExamSprintReportApplicationServiceTest {
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         assertThat(saved.reportType()).isEqualTo(ReportType.OUTLOOK);
         assertThat(saved.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        String expectedFileName = response.reportId() + "-临考词汇突击潜力展望报告-20260102080000.pdf";
+        String expectedFileName = "20260318测试-临考词汇突击潜力展望报告-20260102080000.pdf";
         assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
         assertThat(saved.fileName()).isEqualTo(expectedFileName);
         assertThat(storage.generatedKeys).containsExactly(expectedFileName);
@@ -230,9 +298,9 @@ class ExamSprintReportApplicationServiceTest {
                 .contains(FIXED_CLOCK.instant().toString());
     }
 
-    /** 覆盖同步创建展望报告的调用方词汇报文场景,当 lowercase studentName 为空白时,应回退到 reportId 生成文件名。 */
+    /** 覆盖同步创建展望报告的调用方词汇报文场景,当 lowercase studentName 为空白但 StudentName 有效时,应继续使用 StudentName。 */
     @Test
-    void createOutlookReportSyncFallsBackToReportIdWhenLowercaseStudentNameIsBlank() throws Exception {
+    void createOutlookReportSyncUsesCanonicalStudentNameWhenLowercaseStudentNameIsBlank() throws Exception {
         TestRepository repository = new TestRepository();
         TestStorage storage = new TestStorage();
         DefaultExamSprintReportApplicationService service = service(
@@ -247,11 +315,14 @@ class ExamSprintReportApplicationServiceTest {
         var response = service.createOutlookReportSync(payload);
 
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
-        String expectedFileName = response.reportId() + "-临考词汇突击潜力展望报告-20260102080000.pdf";
+        String expectedFileName = "20260318测试-临考词汇突击潜力展望报告-20260102080000.pdf";
         assertThat(response.downloadUrl()).isEqualTo("/api/exam-sprint/reports/" + response.reportId() + "/download");
         assertThat(saved.storageObjectKey()).isEqualTo(expectedFileName);
         assertThat(saved.fileName()).isEqualTo(expectedFileName);
         assertThat(storage.generatedKeys).containsExactly(expectedFileName);
+        JsonNode savedPayload = (JsonNode) ((UnmodeledReportContent) saved.content()).source();
+        assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
     }
 
     /** 覆盖同步创建成果报告场景,当有效 achievement payload 生成成功时,应上传 PDF 并返回下载地址。 */
@@ -405,7 +476,7 @@ class ExamSprintReportApplicationServiceTest {
                 .doesNotContain("dispatcher unavailable");
     }
 
-    /** 覆盖展望报告 payload 防御性复制场景,当调用方提交后继续修改 StudentName 时,已保存内容应保持原值。 */
+    /** 覆盖展望报告 payload 防御性复制场景,当调用方提交后继续修改 StudentName/studentName 时,已保存内容应保持归一化原值。 */
     @Test
     void createOutlookReportCopiesPayloadBeforeSaving() {
         TestRepository repository = new TestRepository();
@@ -415,11 +486,13 @@ class ExamSprintReportApplicationServiceTest {
         var response = service.createOutlookReport(payload);
 
         payload.put("StudentName", "王同学");
+        payload.put("studentName", "小写别名");
 
         ExamSprintReport saved = repository.findById(response.reportId()).orElseThrow();
         UnmodeledReportContent content = (UnmodeledReportContent) saved.content();
         JsonNode savedPayload = (JsonNode) content.source();
         assertThat(savedPayload.path("StudentName").asText()).isEqualTo("20260318测试");
+        assertThat(savedPayload.path("studentName").asText()).isEqualTo("20260318测试");
     }
 
     /** 覆盖查询成功报告场景,当报告已生成且未过期时,应返回下载地址并记录查询完成日志。 */
@@ -812,6 +885,50 @@ class ExamSprintReportApplicationServiceTest {
                         (Consumer<ObjectNode>) payload -> payload.withObject("examUnknownWordsHitStatus").putArray("hitWords").add(123)));
     }
 
+    private static Stream<Arguments> mixedOutlookPayloadsWithInvalidCanonicalStudentName() {
+        return Stream.of(
+                Arguments.of("blank StudentName falls back to lowercase studentName",
+                        (Consumer<ObjectNode>) payload -> payload.put("StudentName", "   \t  \n ")),
+                Arguments.of("null StudentName falls back to lowercase studentName",
+                        (Consumer<ObjectNode>) payload -> payload.putNull("StudentName")));
+    }
+
+    private static Stream<Arguments> invalidCanonicalStudentNameJsonTypes() {
+        return Stream.of(
+                Arguments.of("number StudentName is rejected without lowercase fallback",
+                        (Consumer<ObjectNode>) payload -> {
+                            payload.put("StudentName", 123);
+                            payload.remove("studentName");
+                        }),
+                Arguments.of("boolean StudentName is rejected without lowercase fallback",
+                        (Consumer<ObjectNode>) payload -> {
+                            payload.put("StudentName", true);
+                            payload.remove("studentName");
+                        }),
+                Arguments.of("object StudentName is rejected without lowercase fallback",
+                        (Consumer<ObjectNode>) payload -> {
+                            payload.set("StudentName", OBJECT_MAPPER.createObjectNode().put("value", "冯亿豪"));
+                            payload.remove("studentName");
+                        }),
+                Arguments.of("array StudentName is rejected without lowercase fallback",
+                        (Consumer<ObjectNode>) payload -> {
+                            payload.set("StudentName", OBJECT_MAPPER.createArrayNode().add("冯亿豪"));
+                            payload.remove("studentName");
+                        }));
+    }
+
+    private static Stream<Arguments> invalidLowercaseStudentNameJsonTypes() {
+        return Stream.of(
+                Arguments.of("number studentName is rejected as fallback source",
+                        (Consumer<ObjectNode>) payload -> payload.put("studentName", 123)),
+                Arguments.of("boolean studentName is rejected as fallback source",
+                        (Consumer<ObjectNode>) payload -> payload.put("studentName", true)),
+                Arguments.of("object studentName is rejected as fallback source",
+                        (Consumer<ObjectNode>) payload -> payload.set("studentName", OBJECT_MAPPER.createObjectNode().put("value", "冯亿豪"))),
+                Arguments.of("array studentName is rejected as fallback source",
+                        (Consumer<ObjectNode>) payload -> payload.set("studentName", OBJECT_MAPPER.createArrayNode().add("冯亿豪"))));
+    }
+
     private void assertCreateOutlookReportRejectsInvalidPayload(JsonNode payload) {
         TestRepository repository = new TestRepository();
         boolean[] dispatched = {false};

+ 64 - 4
abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/ExamSprintReportGenerationWorkerTest.java

@@ -35,7 +35,7 @@ class ExamSprintReportGenerationWorkerTest {
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
     /**
-     * 覆盖 OUTLOOK 报告上传成功且 payload 带 lowercase studentName 时,期望用学生姓名、潜力展望标题和上海时间生成文件名。
+     * 覆盖 OUTLOOK 报告上传成功且 payload 只带 canonical StudentName 时,期望用学生姓名、潜力展望标题和上海时间生成文件名。
      */
     @Test
     void processMarksReportSuccessAfterUpload() {
@@ -43,7 +43,7 @@ class ExamSprintReportGenerationWorkerTest {
         repository.save(ExamSprintReport.pending(
                 "report-success",
                 ReportType.OUTLOOK,
-                unmodeledOutlookContentWithStudentName("冯亿豪"),
+                unmodeledOutlookContentWithCanonicalStudentName("20260318测试"),
                 FIXED_CLOCK.instant(),
                 FIXED_CLOCK.instant().plusSeconds(86400)));
         TestStorage storage = new TestStorage();
@@ -53,8 +53,8 @@ class ExamSprintReportGenerationWorkerTest {
 
         ExamSprintReport report = repository.findById("report-success").orElseThrow();
         assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
-        assertThat(report.storageObjectKey()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
-        assertThat(report.fileName()).isEqualTo("冯亿豪-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.storageObjectKey()).isEqualTo("20260318测试-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.fileName()).isEqualTo("20260318测试-临考词汇突击潜力展望报告-20260101080000.pdf");
     }
 
     /**
@@ -137,6 +137,52 @@ class ExamSprintReportGenerationWorkerTest {
         assertThat(report.fileName()).isEqualTo("report-blank-student-临考词汇突击潜力展望报告-20260101080000.pdf");
     }
 
+    /**
+     * 覆盖 OUTLOOK payload 同时存在 StudentName 和 studentName 时,期望优先使用 canonical StudentName。
+     */
+    @Test
+    void processPrefersCanonicalStudentNameWhenAliasesConflict() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-conflict-student",
+                ReportType.OUTLOOK,
+                unmodeledOutlookContentWithStudentNames("canonical学生", "lowercase学生"),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+        TestStorage storage = new TestStorage();
+        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
+
+        worker.process("report-conflict-student");
+
+        ExamSprintReport report = repository.findById("report-conflict-student").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
+        assertThat(report.storageObjectKey()).isEqualTo("canonical学生-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.fileName()).isEqualTo("canonical学生-临考词汇突击潜力展望报告-20260101080000.pdf");
+    }
+
+    /**
+     * 覆盖 OUTLOOK payload 中 canonical StudentName 为空白时,期望回退到 lowercase studentName。
+     */
+    @Test
+    void processFallsBackToLowercaseStudentNameWhenCanonicalIsBlank() {
+        TestRepository repository = new TestRepository();
+        repository.save(ExamSprintReport.pending(
+                "report-canonical-blank-student",
+                ReportType.OUTLOOK,
+                unmodeledOutlookContentWithStudentNames("   ", "lowercase学生"),
+                FIXED_CLOCK.instant(),
+                FIXED_CLOCK.instant().plusSeconds(86400)));
+        TestStorage storage = new TestStorage();
+        ExamSprintReportGenerationWorker worker = createWorker(repository, List.of(new TestRenderer()), storage);
+
+        worker.process("report-canonical-blank-student");
+
+        ExamSprintReport report = repository.findById("report-canonical-blank-student").orElseThrow();
+        assertThat(report.generationStatus()).isEqualTo(ReportGenerationStatus.SUCCESS);
+        assertThat(report.storageObjectKey()).isEqualTo("lowercase学生-临考词汇突击潜力展望报告-20260101080000.pdf");
+        assertThat(report.fileName()).isEqualTo("lowercase学生-临考词汇突击潜力展望报告-20260101080000.pdf");
+    }
+
     /**
      * 覆盖 OUTLOOK payload 中 lowercase studentName 首尾有空白时,期望 trim 后生成潜力展望文件名。
      */
@@ -280,6 +326,20 @@ class ExamSprintReportGenerationWorkerTest {
                 OBJECT_MAPPER.createObjectNode().put("studentName", studentName));
     }
 
+    private ReportContent unmodeledOutlookContentWithCanonicalStudentName(String studentName) {
+        return new UnmodeledReportContent(
+                ReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode().put("StudentName", studentName));
+    }
+
+    private ReportContent unmodeledOutlookContentWithStudentNames(String canonicalStudentName, String lowercaseStudentName) {
+        return new UnmodeledReportContent(
+                ReportType.OUTLOOK,
+                OBJECT_MAPPER.createObjectNode()
+                        .put("StudentName", canonicalStudentName)
+                        .put("studentName", lowercaseStudentName));
+    }
+
     private ReportContent achievementContentWithStudentName(String studentName) {
         return new AchievementReportContent(
                 studentName,