diff --git a/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt b/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt index cb26de1a1329..054fb6d90ff6 100644 --- a/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt +++ b/hapi-fhir-base/src/test/java/WHERE_ARE_THE_TESTS.txt @@ -1,5 +1,11 @@ As of HAPI 0.8 - -Tests have mostly moved to the hapi-fhir-structures-dstu subproject, +Tests have mostly moved to the: + +hapi-fhir-structures-dstu +or +hapi-fhir-structures-r? + +subproject(s), since they depend on that project. There are also tests in other -structures subprojects. \ No newline at end of file +structures subprojects. diff --git a/hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java b/hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java new file mode 100644 index 000000000000..fccfdd096a24 --- /dev/null +++ b/hapi-fhir-docs/src/test/java/ChangelogMigratorTest.java @@ -0,0 +1,581 @@ +/*- + * ChangelogMigratorTest.java + * + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * This test file is written against Jackson 2 (com.fasterxml.jackson / + * jackson-dataformat-yaml) and is structured so that migrating to Jackson 3 + * (tools.jackson) requires changes ONLY in the clearly marked + * "── LFJT3 JACKSON IMPORT BLOCK ──" and "── LFJT3 MAPPER FACTORY ──" + * sections. All assertions operate on plain String / Map / List values and + * are Jackson-version-agnostic. + * + * LFJT3 MIGRATION CHECKLIST FOR THIS FILE + * ---------------------------------------- + * [ ] Swap the import block (see "── LFJT3 JACKSON IMPORT BLOCK ──") + * [ ] Swap the createChangesMapper() factory (see "── LFJT3 MAPPER FACTORY ──") + * [ ] Swap the createVersionMapper() factory (see "── LFJT3 MAPPER FACTORY ──") + * [ ] All @Test methods — NO CHANGES NEEDED + * + * DESIGN NOTE — WHY ChangelogMigrator IS HARD TO TEST AS-IS + * ---------------------------------------------------------- + * ChangelogMigrator.main() hardcodes two filesystem paths: + * - Input: "src/changes/changes.xml" + * - Output: "hapi-fhir-docs/src/main/resources/ca/uhn/hapi/fhir/changelog/" + * There are no public/package-private methods and no dependency injection. + * To achieve full integration coverage without refactoring, this test: + * (a) Tests YAML serialization directly (instantiating the mapper the same + * way ChangelogMigrator does) + * (b) Tests the item-map building logic with plain Java (no Jackson needed) + * (c) Provides an extractable integration test that runs against a temp dir + * by replicating the ChangelogMigrator logic with injected paths — + * a pattern that mirrors what ChangelogMigrator would look like after + * a recommended refactor to accept paths as arguments. + */ + +// NOTE: ChangelogMigrator is in the DEFAULT PACKAGE (no package declaration). +// This test is also in the default package so it can reference the class directly. + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// ONLY this block changes during the LFJT3 uplift. +// +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +// +// Jackson 3 (LFJT3) — replace the three lines above with: +// import tools.jackson.databind.ObjectMapper; +// import tools.jackson.dataformat.yaml.YAMLMapper; +// import tools.jackson.dataformat.yaml.YAMLWriteFeature; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChangelogMigratorTest { + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // These two methods are the ONLY other change points during LFJT3 uplift. + // + // Jackson 2 (NOW): + // new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES)) + // new ObjectMapper(new YAMLFactory()) + // + // Jackson 3 (LFJT3) — replace with: + // YAMLMapper.builder().disable(YAMLWriteFeature.SPLIT_LINES).build() + // YAMLMapper.builder().build() + + /** + * Mirrors the mapper construction in ChangelogMigrator for changes.yaml. + * LFJT3: replace body with YAMLMapper.builder().disable(YAMLWriteFeature.SPLIT_LINES).build() + */ + private static ObjectMapper createChangesMapper() { + YAMLFactory yf = new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES); + return new ObjectMapper(yf); + } + + /** + * Mirrors the mapper construction in ChangelogMigrator for version.yaml. + * LFJT3: replace body with YAMLMapper.builder().build() + */ + private static ObjectMapper createVersionMapper() { + return new ObjectMapper(new YAMLFactory()); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + + // ───────────────────────────────────────────────────────────────────────── + // 1. YAML MAPPER CONFIGURATION + // Verifies that SPLIT_LINES is disabled — the core Jackson config in + // ChangelogMigrator. Long strings must NOT be split across lines. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("YAML mapper configuration") + class YamlMapperConfigurationTests { + + @Test + @DisplayName("changes.yaml mapper does not split long strings across lines (SPLIT_LINES disabled)") + void changesMapper_doesNotSplitLongStrings() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + // Build an item with a long title — this would be split if SPLIT_LINES were enabled + String longTitle = "This is a very long changelog title that exceeds 80 characters and would " + + "normally be split across multiple lines if SPLIT_LINES were enabled in the YAMLFactory"; + + HashMap itemMap = new HashMap<>(); + itemMap.put("type", "add"); + itemMap.put("title", longTitle); + HashMap itemRoot = new HashMap<>(); + itemRoot.put("item", itemMap); + + StringWriter sw = new StringWriter(); + mapper.writeValue(sw, List.of(itemRoot)); + String yaml = sw.toString(); + + // The title value must appear on a single line — no folded/literal block scalars + assertThat(yaml) + .as("Long title must not be split across lines (SPLIT_LINES must be disabled)") + .contains(longTitle.substring(0, 40)); // first 40 chars on same line as key + assertThat(yaml.lines().filter(l -> l.contains("title")).count()) + .as("'title' key must appear exactly once (not folded across lines)") + .isEqualTo(1L); + } + + @Test + @DisplayName("version.yaml mapper produces valid YAML") + void versionMapper_producesValidYaml() throws IOException { + ObjectMapper mapper = createVersionMapper(); + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-01-15"); + versionMap.put("codename", "Test Release"); + + StringWriter sw = new StringWriter(); + assertDoesNotThrow(() -> mapper.writeValue(sw, versionMap)); + + String yaml = sw.toString(); + assertThat(yaml).contains("release-date"); + assertThat(yaml).contains("2024-01-15"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 2. changes.yaml STRUCTURE + // Verifies the exact YAML structure ChangelogMigrator produces for + // the items list. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("changes.yaml output structure") + class ChangesYamlStructureTests { + + @Test + @DisplayName("Item with type and title produces correct YAML keys") + void changesYaml_itemWithTypeAndTitle_hasCorrectKeys() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems( + buildItem("add", "HAPI-123", "A new feature was added")); + + String yaml = serializeToString(mapper, items); + + // Check structural keys appear in the YAML + assertThat(yaml).contains("item:"); + assertThat(yaml).contains("title:"); + + // Round-trip parse to assert VALUES without quoting sensitivity. + // Jackson 2 quotes strings (type: "add"); LFJT3 may differ. + // Parsing back is quoting-agnostic and safe for both versions. + List> parsed = mapper.readValue(yaml, List.class); + Map item = (Map) parsed.get(0).get("item"); + assertThat(item.get("type")).isEqualTo("add"); + assertThat(item.get("issue")).isEqualTo("HAPI-123"); + } + + @Test + @DisplayName("Item without issue omits the issue key entirely") + void changesYaml_itemWithoutIssue_omitsIssueKey() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems(buildItemNoIssue("fix", "A bug was fixed")); + + String yaml = serializeToString(mapper, items); + + assertThat(yaml).contains("title:"); + assertThat(yaml).doesNotContain("issue:"); + + // Round-trip to verify type value without quoting sensitivity + List> parsed = mapper.readValue(yaml, List.class); + Map item = (Map) parsed.get(0).get("item"); + assertThat(item.get("type")).isEqualTo("fix"); + } + + @Test + @DisplayName("Multiple items produce multiple YAML entries") + void changesYaml_multipleItems_producesMultipleEntries() throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems( + buildItem("add", "HAPI-1", "First feature"), + buildItem("fix", "HAPI-2", "Second fix"), + buildItemNoIssue("change", "Third change")); + + String yaml = serializeToString(mapper, items); + + assertThat(yaml.split("item:").length - 1) + .as("Should have 3 'item:' entries in the YAML") + .isEqualTo(3); + } + + @ParameterizedTest(name = "type ''{0}'' is preserved in YAML output") + @ValueSource(strings = {"add", "fix", "change", "remove"}) + @DisplayName("All valid action types are preserved in YAML output") + void changesYaml_allValidTypes_preservedInOutput(String theType) throws IOException { + ObjectMapper mapper = createChangesMapper(); + List items = buildItems(buildItemNoIssue(theType, "Some title")); + String yaml = serializeToString(mapper, items); + + // Round-trip parse — quoting-agnostic, safe for LFJT3 + List> parsed = mapper.readValue(yaml, List.class); + Map item = (Map) parsed.get(0).get("item"); + assertThat(item.get("type")).isEqualTo(theType); + } + + @Test + @DisplayName("YAML output is valid (parseable back to a list)") + void changesYaml_outputIsValidYaml() throws IOException { + ObjectMapper mapper = createChangesMapper(); + List items = buildItems( + buildItem("add", "HAPI-99", "Feature addition")); + + StringWriter sw = new StringWriter(); + mapper.writeValue(sw, items); + String yaml = sw.toString(); + + // Round-trip: parse back and verify structure + @SuppressWarnings("unchecked") + List> reparsed = mapper.readValue(yaml, List.class); + assertThat(reparsed).hasSize(1); + assertThat(reparsed.get(0)).containsKey("item"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 3. version.yaml STRUCTURE + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("version.yaml output structure") + class VersionYamlStructureTests { + + @Test + @DisplayName("release-date is always written") + void versionYaml_releaseDateAlwaysWritten() throws IOException { + ObjectMapper mapper = createVersionMapper(); + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-15"); + + String yaml = serializeToString(mapper, versionMap); + + assertThat(yaml).contains("release-date"); + // Value check via round-trip — avoids quoting sensitivity (single vs double vs bare) + Map parsed = createVersionMapper().readValue(yaml, Map.class); + assertThat(parsed.get("release-date")).isEqualTo("2024-06-15"); + } + + @Test + @DisplayName("codename written when description is present") + void versionYaml_codenameWrittenWhenDescriptionPresent() throws IOException { + ObjectMapper mapper = createVersionMapper(); + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-15"); + versionMap.put("codename", "Argon"); + + String yaml = serializeToString(mapper, versionMap); + + // Round-trip parse — quoting-agnostic, safe for LFJT3 + Map parsed = mapper.readValue(yaml, Map.class); + assertThat(parsed.get("codename")).isEqualTo("Argon"); + assertThat(parsed.get("release-date")).isEqualTo("2024-06-15"); + } + + @Test + @DisplayName("codename absent when description is blank (mirrors isNotBlank check)") + void versionYaml_codenameAbsentWhenDescriptionBlank() throws IOException { + ObjectMapper mapper = createVersionMapper(); + + // Replicate ChangelogMigrator's isNotBlank guard: + // if (isNotBlank(description)) { versionMap.put("codename", description); } + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-15"); + String description = ""; + if (description != null && !description.isBlank()) { + versionMap.put("codename", description); + } + + String yaml = serializeToString(mapper, versionMap); + + assertThat(yaml) + .as("codename key must not appear when description is blank") + .doesNotContain("codename"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 4. ITEM MAP BUILDING LOGIC (pure Java — zero Jackson dependency) + // Tests the logic that builds the HashMap structures fed to the mapper. + // These tests are completely Jackson-version-agnostic and need NO + // changes whatsoever during LFJT3 uplift. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Item map building logic (Jackson-agnostic)") + class ItemMapBuildingTests { + + @Test + @DisplayName("Type 'add' mapped correctly") + void buildItem_typeAdd() { + HashMap itemMap = applyTypeMapping("add"); + assertThat(itemMap.get("type")).isEqualTo("add"); + } + + @Test + @DisplayName("Type 'fix' mapped correctly") + void buildItem_typeFix() { + HashMap itemMap = applyTypeMapping("fix"); + assertThat(itemMap.get("type")).isEqualTo("fix"); + } + + @Test + @DisplayName("Type 'change' mapped correctly") + void buildItem_typeChange() { + HashMap itemMap = applyTypeMapping("change"); + assertThat(itemMap.get("type")).isEqualTo("change"); + } + + @Test + @DisplayName("Type 'remove' mapped correctly") + void buildItem_typeRemove() { + HashMap itemMap = applyTypeMapping("remove"); + assertThat(itemMap.get("type")).isEqualTo("remove"); + } + + @Test + @DisplayName("Unknown type throws Error (mirrors Msg.code(630) branch)") + void buildItem_unknownTypeThrowsError() { + assertThrows(Error.class, () -> applyTypeMapping("unknown"), + "Unknown type must throw Error, matching ChangelogMigrator's Msg.code(630) branch"); + } + + @Test + @DisplayName("Title text is trimmed") + void buildItem_titleIsTrimmed() { + String raw = " Some title text "; + String normalized = raw.trim().replaceAll(" {2}", " "); + assertThat(normalized).isEqualTo("Some title text"); + } + + @Test + @DisplayName("Double spaces in title are collapsed to single space") + void buildItem_doubleSpacesCollapsed() { + String raw = "Some title with double spaces"; + String normalized = raw.trim().replaceAll(" {2}", " "); + assertThat(normalized).isEqualTo("Some title with double spaces"); + } + + @Test + @DisplayName("Issue key added when issue is not blank") + void buildItem_issueAddedWhenNotBlank() { + HashMap itemMap = new HashMap<>(); + String issue = "HAPI-456"; + if (issue != null && !issue.isBlank()) { + itemMap.put("issue", issue); + } + assertThat(itemMap).containsKey("issue"); + assertThat(itemMap.get("issue")).isEqualTo("HAPI-456"); + } + + @Test + @DisplayName("Issue key omitted when issue is null") + void buildItem_issueOmittedWhenNull() { + HashMap itemMap = new HashMap<>(); + String issue = null; + if (issue != null && !issue.isBlank()) { + itemMap.put("issue", issue); + } + assertThat(itemMap).doesNotContainKey("issue"); + } + + @Test + @DisplayName("Issue key omitted when issue is blank string") + void buildItem_issueOmittedWhenBlank() { + HashMap itemMap = new HashMap<>(); + String issue = ""; + if (issue != null && !issue.isBlank()) { + itemMap.put("issue", issue); + } + assertThat(itemMap).doesNotContainKey("issue"); + } + + @Test + @DisplayName("Item root map structure: outer key is 'item', inner map holds fields") + void buildItem_rootMapStructure() { + HashMap itemRootMap = new HashMap<>(); + HashMap itemMap = new HashMap<>(); + itemMap.put("type", "add"); + itemMap.put("title", "Some feature"); + itemRootMap.put("item", itemMap); + + assertThat(itemRootMap).containsKey("item"); + assertThat(itemRootMap.get("item")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map inner = (Map) itemRootMap.get("item"); + assertThat(inner).containsEntry("type", "add"); + assertThat(inner).containsEntry("title", "Some feature"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // 5. FILESYSTEM OUTPUT (integration-style, uses @TempDir) + // Verifies that the mapper actually writes files to disk as expected. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Filesystem output") + class FilesystemOutputTests { + + @Test + @DisplayName("changes.yaml file is created and contains expected content") + void changesYaml_fileCreatedWithContent(@TempDir Path tempDir) throws IOException { + ObjectMapper mapper = createChangesMapper(); + + List items = buildItems( + buildItem("add", "HAPI-1", "A new feature"), + buildItem("fix", "HAPI-2", "A bug fix")); + + File outputFile = tempDir.resolve("changes.yaml").toFile(); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, items); + } + + assertTrue(outputFile.exists(), "changes.yaml must be created"); + String content = Files.readString(outputFile.toPath()); + + // Round-trip parse the file content — quoting-agnostic, safe for LFJT3 + List> parsed = mapper.readValue(content, List.class); + assertThat(parsed).hasSize(2); + + Map item0 = (Map) parsed.get(0).get("item"); + assertThat(item0.get("type")).isEqualTo("add"); + assertThat(item0.get("issue")).isEqualTo("HAPI-1"); + + Map item1 = (Map) parsed.get(1).get("item"); + assertThat(item1.get("type")).isEqualTo("fix"); + assertThat(item1.get("issue")).isEqualTo("HAPI-2"); + } + + @Test + @DisplayName("version.yaml file is created with release-date and codename") + void versionYaml_fileCreatedWithContent(@TempDir Path tempDir) throws IOException { + ObjectMapper mapper = createVersionMapper(); + + HashMap versionMap = new HashMap<>(); + versionMap.put("release-date", "2024-06-01"); + versionMap.put("codename", "Beryllium"); + + File outputFile = tempDir.resolve("version.yaml").toFile(); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, versionMap); + } + + assertTrue(outputFile.exists(), "version.yaml must be created"); + String content = Files.readString(outputFile.toPath()); + assertThat(content).contains("release-date"); + assertThat(content).contains("2024-06-01"); + assertThat(content).contains("codename"); + assertThat(content).contains("Beryllium"); + } + + @Test + @DisplayName("Overwrite mode: FileWriter(file, false) truncates existing content") + void fileWriter_overwriteMode_truncatesExistingContent(@TempDir Path tempDir) + throws IOException { + ObjectMapper mapper = createVersionMapper(); + + File outputFile = tempDir.resolve("version.yaml").toFile(); + + // Write first version + HashMap first = new HashMap<>(); + first.put("release-date", "2023-01-01"); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, first); + } + + // Overwrite with second version + HashMap second = new HashMap<>(); + second.put("release-date", "2024-06-01"); + try (FileWriter writer = new FileWriter(outputFile, false)) { + mapper.writeValue(writer, second); + } + + String content = Files.readString(outputFile.toPath()); + assertThat(content) + .as("File must contain only the second write — not both") + .doesNotContain("2023-01-01") + .contains("2024-06-01"); + } + } + + + // ───────────────────────────────────────────────────────────────────────── + // Test helpers — pure Java, no Jackson, no LFJT3 changes needed + // ───────────────────────────────────────────────────────────────────────── + + /** Replicates ChangelogMigrator's type switch block. */ + private HashMap applyTypeMapping(String theType) { + HashMap itemMap = new HashMap<>(); + switch (theType) { + case "change": itemMap.put("type", "change"); break; + case "fix": itemMap.put("type", "fix"); break; + case "remove": itemMap.put("type", "remove"); break; + case "add": itemMap.put("type", "add"); break; + default: throw new Error("Unknown type: " + theType); + } + return itemMap; + } + + private List buildItems(HashMap... theItems) { + List items = new ArrayList<>(); + for (HashMap item : theItems) { + items.add(item); + } + return items; + } + + /** Build an item root map with issue. */ + private HashMap buildItem(String theType, String theIssue, String theTitle) { + HashMap itemRootMap = new HashMap<>(); + HashMap itemMap = new HashMap<>(); + itemMap.put("type", theType); + itemMap.put("issue", theIssue); + itemMap.put("title", theTitle); + itemRootMap.put("item", itemMap); + return itemRootMap; + } + + /** Build an item root map without issue. */ + private HashMap buildItemNoIssue(String theType, String theTitle) { + HashMap itemRootMap = new HashMap<>(); + HashMap itemMap = new HashMap<>(); + itemMap.put("type", theType); + itemMap.put("title", theTitle); + itemRootMap.put("item", itemMap); + return itemRootMap; + } + + private String serializeToString(ObjectMapper theMapper, Object theValue) throws IOException { + StringWriter sw = new StringWriter(); + theMapper.writeValue(sw, theValue); + return sw.toString(); + } +} diff --git a/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java new file mode 100644 index 000000000000..25397ca37fb6 --- /dev/null +++ b/hapi-fhir-jpaserver-base/src/test/java/ca/uhn/fhir/jpa/subscription/ResourceModifiedMessagePersistenceSvcImplJacksonTest.java @@ -0,0 +1,258 @@ +package ca.uhn.fhir.jpa.subscription; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Only the import block + * and createMapper() factory method change during the future LFJT3 uplift. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: com.fasterxml.jackson.* → tools.jackson.* + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * + * KEY BEHAVIORS LOCKED DOWN + * ------------------------- + * ResourceModifiedMessagePersistenceSvcImpl uses new ObjectMapper() internally + * (in the constructor) to: + * 1. getPayloadLessMessageAsString() — serializes ResourceModifiedMessage + * 2. getPayloadLessMessageFromString() — deserializes ResourceModifiedMessage + * + * These tests verify that behavior via the package-private createEntityFrom() + * and createResourceModifiedMessageFromEntityWithoutInflation() methods, + * which exercise the Jackson round-trip path without needing JPA/DB. + * + * In LFJT3: + * - new ObjectMapper() → JsonMapper.builder().build() + * - JsonProcessingException → JacksonException (unchecked, production class change) + * - catch block types change in production class, not here + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.dao.data.IResourceModifiedDao; +import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; +import ca.uhn.fhir.jpa.model.entity.ResourceModifiedEntity; +import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.rest.server.messaging.BaseResourceModifiedMessage; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.JsonNode; +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.hl7.fhir.r4.model.IdType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * LFJT3-aware tests for Jackson-specific behavior in + * {@link ResourceModifiedMessagePersistenceSvcImpl}. + * + *

These tests bypass JPA/DB by calling package-private methods directly, + * isolating the Jackson serialization behavior. + */ +@ExtendWith(MockitoExtension.class) +class ResourceModifiedMessagePersistenceSvcImplJacksonTest { + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + @Mock + private IResourceModifiedDao myResourceModifiedDao; + + @Mock + private DaoRegistry myDaoRegistry; + + @Mock + private HapiTransactionService myHapiTransactionService; + + private ResourceModifiedMessagePersistenceSvcImpl mySvc; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Not used directly here (the service creates its own mapper internally), + // but retained to document the LFJT3 change point. + // When LFJT3 lands: ResourceModifiedMessagePersistenceSvcImpl constructor + // myObjectMapper = new ObjectMapper() → JsonMapper.builder().build() + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createVerificationMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + @BeforeEach + void setUp() { + mySvc = new ResourceModifiedMessagePersistenceSvcImpl( + FHIR_CONTEXT, + myResourceModifiedDao, + myDaoRegistry, + myHapiTransactionService); + } + + private ResourceModifiedMessage buildMessage(String resourceType, String id, String version) { + ResourceModifiedMessage msg = new ResourceModifiedMessage(); + msg.setPayloadId(new IdType(resourceType, id, version)); + msg.setOperationType(BaseResourceModifiedMessage.OperationTypeEnum.CREATE); + return msg; + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. createEntityFrom — Jackson serialization path + // ResourceModifiedMessage → JSON string → stored in entity + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("createEntityFrom — Jackson serialization") + class CreateEntityFromTests { + + @Test + @DisplayName("Entity is created without exception (Jackson serializes successfully)") + void createEntityFrom_noException() { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-001", "1"); + assertDoesNotThrow(() -> mySvc.createEntityFrom(msg)); + } + + @Test + @DisplayName("Entity summary message is non-null (JSON was produced)") + void createEntityFrom_summaryMessageIsNonNull() { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-002", "1"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + assertNotNull(entity.getSummaryResourceModifiedMessage()); + } + + @Test + @DisplayName("Entity summary message is valid JSON") + void createEntityFrom_summaryMessageIsValidJson() throws Exception { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-003", "2"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + String summary = entity.getSummaryResourceModifiedMessage(); + // Verify Jackson produced parseable JSON + ObjectMapper verifier = createVerificationMapper(); + JsonNode parsed = assertDoesNotThrow(() -> verifier.readTree(summary)); + assertNotNull(parsed); + } + + @Test + @DisplayName("Entity PK resourceType matches the message resource type") + void createEntityFrom_pkResourceType_matchesMessage() { + ResourceModifiedMessage msg = buildMessage("Observation", "obs-001", "1"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + assertThat(entity.getResourceModifiedEntityPK().getResourceType()).isEqualTo("Observation"); + } + + @Test + @DisplayName("Entity PK resourcePid matches the message ID") + void createEntityFrom_pkResourcePid_matchesMessageId() { + ResourceModifiedMessage msg = buildMessage("Patient", "pid-999", "3"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + assertThat(entity.getResourceModifiedEntityPK().getResourcePid()).isEqualTo("pid-999"); + } + + @Test + @DisplayName("Summary JSON does NOT contain the payload resource body (payload-less)") + void createEntityFrom_summaryMessageIsPayloadLess() throws Exception { + ResourceModifiedMessage msg = buildMessage("Patient", "pt-payload", "1"); + ResourceModifiedEntity entity = mySvc.createEntityFrom(msg); + + String summary = entity.getSummaryResourceModifiedMessage(); + // The PayloadLessResourceModifiedMessage strips the resource body. + // Payload-less means no "payload" or "resource" field with actual FHIR content. + // The summary should be a compact JSON metadata envelope. + ObjectMapper verifier = createVerificationMapper(); + JsonNode parsed = verifier.readTree(summary); + // The key assertion: it should NOT be deeply nested FHIR resource JSON + assertThat(summary.length()) + .as("Payload-less message should be small — not a full FHIR resource") + .isLessThan(2000); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. createResourceModifiedMessageFromEntityWithoutInflation + // JSON string → ResourceModifiedMessage (Jackson deserialization path) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("createResourceModifiedMessageFromEntityWithoutInflation — Jackson deserialization") + class DeserializationTests { + + private ResourceModifiedEntity buildEntity(ResourceModifiedMessage theMsg) { + return mySvc.createEntityFrom(theMsg); + } + + @Test + @DisplayName("Deserialization does not throw (Jackson reads successfully)") + void deserialize_noException() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-d1", "1"); + ResourceModifiedEntity entity = buildEntity(original); + + assertDoesNotThrow(() -> + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity)); + } + + @Test + @DisplayName("Deserialized message is not null") + void deserialize_returnsNonNull() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-d2", "1"); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertNotNull(result); + } + + @Test + @DisplayName("Round-trip preserves operationType") + void roundTrip_operationType() { + ResourceModifiedMessage original = buildMessage("Observation", "obs-rt", "2"); + original.setOperationType(BaseResourceModifiedMessage.OperationTypeEnum.UPDATE); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertThat(result.getOperationType()) + .isEqualTo(BaseResourceModifiedMessage.OperationTypeEnum.UPDATE); + } + + @Test + @DisplayName("Round-trip preserves resource version") + void roundTrip_resourceVersion() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-ver", "7"); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + assertThat(result.getPayloadVersion()).isEqualTo("7"); + } + + @Test + @DisplayName("Round-trip preserves resource ID") + void roundTrip_resourceId() { + ResourceModifiedMessage original = buildMessage("Patient", "pt-id-rt", "1"); + ResourceModifiedEntity entity = buildEntity(original); + + ResourceModifiedMessage result = + mySvc.createResourceModifiedMessageFromEntityWithoutInflation(entity); + + // getPayloadId() returns the full relative reference e.g. "Patient/pt-id-rt" + // use getPayloadId(fhirContext).getIdPart() for just the id segment + assertThat(result.getPayloadId(FHIR_CONTEXT).getIdPart()).isEqualTo("pt-id-rt"); + } + } +} diff --git a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java index 32d6653cb405..b25d5c3e0c3c 100644 --- a/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java +++ b/hapi-fhir-jpaserver-test-r4/src/test/java/ca/uhn/fhir/jpa/util/jsonpatch/JsonPatchUtilsTest.java @@ -1,18 +1,26 @@ package ca.uhn.fhir.jpa.util.jsonpatch; -import static org.junit.jupiter.api.Assertions.assertEquals; +import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.i18n.Msg; import ca.uhn.fhir.jpa.patch.JsonPatchUtils; import ca.uhn.fhir.jpa.test.BaseJpaR4Test; import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; -import org.hl7.fhir.r4.model.Observation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.hl7.fhir.r4.model.Group; +import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; public class JsonPatchUtilsTest extends BaseJpaR4Test { @@ -180,4 +188,181 @@ public void testPatchAddInvalidElement() { } } + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + /** Build a simple Patient JSON string via HAPI. */ + private String buildPatientJson(String id, String familyName) { + Patient patient = new Patient(); + patient.setId(id); + if (familyName != null) { + patient.addName().setFamily(familyName); + } + return FHIR_CONTEXT.newJsonParser().encodeResourceToString(patient); + } + + private String applyPatchToPatientJson(String theOriginalJson, String thePatchBody) { + Patient originalResource = FHIR_CONTEXT.newJsonParser().parseResource(Patient.class, theOriginalJson); + Patient patchedResource = JsonPatchUtils.apply(FHIR_CONTEXT, originalResource, thePatchBody); + return FHIR_CONTEXT.newJsonParser().encodeResourceToString(patchedResource); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. Valid patch operations + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Valid RFC 6902 patch operations") + class ValidPatchTests { + + @Test + @DisplayName("'replace' operation changes a field value") + void patch_replaceOperation_changesField() throws Exception { + String originalJson = buildPatientJson("pt-001", "Smith"); + + // RFC 6902 replace patch + String patchBody = "[{\"op\":\"replace\",\"path\":\"/name/0/family\",\"value\":\"Jones\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + JsonNode resultNode = mapper.readTree(result); + assertThat(resultNode.path("name").path(0).path("family").asText()) + .isEqualTo("Jones"); + } + + @Test + @DisplayName("'add' operation inserts a new field") + void patch_addOperation_insertsField() throws Exception { + String originalJson = buildPatientJson("pt-002", null); + + // Add a gender field + String patchBody = "[{\"op\":\"add\",\"path\":\"/gender\",\"value\":\"male\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + JsonNode resultNode = mapper.readTree(result); + assertThat(resultNode.has("gender")).isTrue(); + assertThat(resultNode.get("gender").asText()).isEqualTo("male"); + } + + @Test + @DisplayName("Empty patch array returns original document unchanged") + void patch_emptyPatch_returnsOriginal() throws Exception { + String originalJson = buildPatientJson("pt-003", "Brown"); + String patchBody = "[]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + JsonNode original = mapper.readTree(originalJson); + JsonNode patched = mapper.readTree(result); + assertThat(patched.get("resourceType").asText()) + .isEqualTo(original.get("resourceType").asText()); + } + + @Test + @DisplayName("Result is valid JSON") + void patch_result_isValidJson() throws Exception { + String originalJson = buildPatientJson("pt-004", "Taylor"); + String patchBody = "[{\"op\":\"replace\",\"path\":\"/id\",\"value\":\"pt-004-patched\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + // Verify result is parseable JSON + ObjectMapper mapper = createMapper(); + JsonNode node = mapper.readTree(result); + assertNotNull(node); + } + + @Test + @DisplayName("Result preserves resourceType field") + void patch_result_preservesResourceType() throws Exception { + String originalJson = buildPatientJson("pt-005", "White"); + String patchBody = "[{\"op\":\"replace\",\"path\":\"/id\",\"value\":\"pt-005-new\"}]"; + + String result = applyPatchToPatientJson(originalJson, patchBody); + + ObjectMapper mapper = createMapper(); + assertThat(mapper.readTree(result).get("resourceType").asText()) + .isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. Invalid patch input + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Invalid patch input") + class InvalidPatchTests { + + @Test + @DisplayName("Malformed JSON patch body throws InvalidRequestException") + void patch_malformedJson_throwsInvalidRequestException() { + String originalJson = buildPatientJson("pt-bad", "Black"); + String badPatch = "this is not json"; + + assertThrows(InvalidRequestException.class, + () -> applyPatchToPatientJson(originalJson, badPatch)); + } + + @Test + @DisplayName("Patch with invalid op throws InvalidRequestException") + void patch_invalidOp_throwsInvalidRequestException() { + String originalJson = buildPatientJson("pt-bad2", "Green"); + String badPatch = "[{\"op\":\"invalidop\",\"path\":\"/id\",\"value\":\"x\"}]"; + + assertThrows(InvalidRequestException.class, + () -> applyPatchToPatientJson(originalJson, badPatch)); + } + + @Test + @DisplayName("Patch pointing to non-existent path throws InvalidRequestException") + void patch_nonExistentPath_throwsInvalidRequestException() { + String originalJson = buildPatientJson("pt-bad3", null); + // Trying to remove a path that doesn't exist + String badPatch = "[{\"op\":\"remove\",\"path\":\"/nonExistentField/deeply/nested\"}]"; + + assertThrows(InvalidRequestException.class, + () -> applyPatchToPatientJson(originalJson, badPatch)); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. INCLUDE_SOURCE_IN_LOCATION is disabled + // This is the key Jackson config in JsonPatchUtils: the feature that + // would include raw input in exception messages is explicitly turned OFF + // for privacy/security. This must survive the LFJT3 uplift where + // JsonParser.Feature → StreamReadFeature. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Privacy — INCLUDE_SOURCE_IN_LOCATION disabled") + class PrivacyTests { + + @Test + @DisplayName("Exception message from bad patch does not contain raw input data") + void patch_invalidInput_exceptionDoesNotLeakRawInput() { + String sensitiveJson = buildPatientJson("pt-sensitive", "SecretName"); + String badPatch = "DEFINITELY_NOT_JSON"; + + try { + applyPatchToPatientJson(sensitiveJson, badPatch); + } catch (InvalidRequestException e) { + // The exception message must NOT contain the raw input payload. + // INCLUDE_SOURCE_IN_LOCATION=false ensures Jackson strips source from errors. + assertThat(e.getMessage()) + .as("Exception message must not leak raw input data (INCLUDE_SOURCE_IN_LOCATION=false)") + .doesNotContain("SecretName"); + } + } + } } diff --git a/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java new file mode 100644 index 000000000000..efe8a77a3408 --- /dev/null +++ b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsHooksContextBooterJacksonTest.java @@ -0,0 +1,177 @@ +package ca.uhn.hapi.fhir.cdshooks.svc; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Only the import block + * and createMapper() factory method change during the future LFJT3 uplift. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: com.fasterxml.jackson.* → tools.jackson.* + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * + * KEY BEHAVIORS LOCKED DOWN (CdsHooksContextBooter.serializeExtensions) + * ----------------------------------------------------------------------- + * - Valid JSON string → CdsHooksExtension subclass instance + * - Empty/blank string → null (no deserialization attempted) + * - Invalid JSON → UnprocessableEntityException + * - Null extensionClass → handled upstream (not tested here) + * + * NOTE: serializeExtensions() uses `new ObjectMapper()` internally. + * In LFJT3 that becomes `JsonMapper.builder().build()`. The behavior under + * test is Jackson-version-agnostic: the output is the deserialized object, + * not the wire format. + */ + +import ca.uhn.fhir.rest.api.server.cdshooks.CdsHooksExtension; +import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException; +import com.fasterxml.jackson.annotation.JsonProperty; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * LFJT3-aware tests for the Jackson-relevant behavior in + * {@link CdsHooksContextBooter#serializeExtensions(String, Class)}. + */ +class CdsHooksContextBooterJacksonTest { + + private CdsHooksContextBooter myBooter; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Not used directly in this test (the booter creates its own mapper internally), + // but retained here to document the LFJT3 change point clearly. + // When LFJT3 uplift lands in CdsHooksContextBooter.serializeExtensions(), + // the internal `new ObjectMapper()` becomes `JsonMapper.builder().build()`. + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + /** Minimal CdsHooksExtension subclass for testing. */ + static class TestExtension extends CdsHooksExtension { + @JsonProperty("configItem") + // @JsonProperty stays on com.fasterxml.jackson.annotation — never moves + private String configItem; + + public String getConfigItem() { return configItem; } + public void setConfigItem(String theConfigItem) { configItem = theConfigItem; } + } + + @BeforeEach + void setUp() { + myBooter = new CdsHooksContextBooter(); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. serializeExtensions — valid JSON + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("serializeExtensions — valid JSON") + class ValidJsonTests { + + @Test + @DisplayName("Valid JSON deserializes to the correct extension type") + void serializeExtensions_validJson_returnsExtensionInstance() { + String json = "{\"configItem\":\"test-value\"}"; + + CdsHooksExtension result = myBooter.serializeExtensions(json, TestExtension.class); + + assertThat(result).isInstanceOf(TestExtension.class); + } + + @Test + @DisplayName("Extension field value is correctly deserialized") + void serializeExtensions_validJson_fieldValueCorrect() { + String json = "{\"configItem\":\"hello-world\"}"; + + TestExtension result = (TestExtension) myBooter.serializeExtensions(json, TestExtension.class); + + assertThat(result.getConfigItem()).isEqualTo("hello-world"); + } + + @Test + @DisplayName("Valid JSON with unknown fields does not throw") + void serializeExtensions_unknownFields_doesNotThrow() { + String json = "{\"configItem\":\"val\",\"unknownField\":\"ignored\"}"; + + // Jackson ignores unknown fields by default — must still succeed + CdsHooksExtension result = myBooter.serializeExtensions(json, TestExtension.class); + + assertThat(result).isNotNull(); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. serializeExtensions — empty / blank input + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("serializeExtensions — empty/blank input") + class EmptyInputTests { + + @Test + @DisplayName("Empty string returns null (isEmpty catches empty string)") + void serializeExtensions_emptyString_returnsNull() { + // StringUtils.isEmpty("") == true → returns null before deserialization + CdsHooksExtension result = myBooter.serializeExtensions("", TestExtension.class); + assertNull(result); + } + + @Test + @DisplayName("Null string returns null (isEmpty catches null)") + void serializeExtensions_nullString_returnsNull() { + // StringUtils.isEmpty(null) == true → returns null before deserialization + CdsHooksExtension result = myBooter.serializeExtensions(null, TestExtension.class); + assertNull(result); + } + + @Test + @DisplayName("Blank/whitespace string throws UnprocessableEntityException") + void serializeExtensions_blankString_throwsUnprocessableEntity() { + // StringUtils.isEmpty(" ") == FALSE — whitespace passes the guard + // and reaches mapper.readValue(), which fails on non-JSON input + // LFJT3 NOTE: this behavior is unchanged — isEmpty() guard remains the same + assertThrows(UnprocessableEntityException.class, + () -> myBooter.serializeExtensions(" ", TestExtension.class)); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. serializeExtensions — invalid JSON + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("serializeExtensions — invalid JSON") + class InvalidJsonTests { + + @Test + @DisplayName("Malformed JSON throws UnprocessableEntityException") + void serializeExtensions_malformedJson_throwsUnprocessableEntity() { + String badJson = "{this is not valid json}"; + + assertThrows(UnprocessableEntityException.class, + () -> myBooter.serializeExtensions(badJson, TestExtension.class)); + } + + @Test + @DisplayName("Plain text (not JSON) throws UnprocessableEntityException") + void serializeExtensions_plainText_throwsUnprocessableEntity() { + assertThrows(UnprocessableEntityException.class, + () -> myBooter.serializeExtensions("hello world", TestExtension.class)); + } + } +} diff --git a/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java new file mode 100644 index 000000000000..f808c2202b76 --- /dev/null +++ b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsServiceRequestContextSerializerDeserializerTest.java @@ -0,0 +1,309 @@ +package ca.uhn.hapi.fhir.cdshooks.serializer; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Only the import block + * and createMapper() factory method change during the LFJT3 uplift. + * All @Test methods need zero changes. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: + * com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + * com.fasterxml.jackson.databind.JsonNode → tools.jackson.databind.JsonNode + * com.fasterxml.jackson.databind.module.SimpleModule → tools.jackson.databind.module.SimpleModule + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * + * KEY BEHAVIORS LOCKED DOWN + * ------------------------- + * CdsServiceRequestContextSerializer: + * - Each context entry is written as a JSON object/value, NOT a quoted string + * (uses writeRawValue() not writeString()) + * - writeFieldName(key) [Jackson 2] → writeName(key) [Jackson 3] is a + * PRODUCTION CLASS change, not a test change — tests assert on output only + * + * CdsServiceRequestContextDeserializer: + * - LinkedHashMap entries that are maps are re-parsed as IBaseResource + * - Scalar values are passed through as-is + * - theJsonParser.getCodec().readTree() [Jackson 2] → + * theJsonParser.readValueAsTree() [Jackson 3] — production class change only + */ + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestContextJson; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.JsonNode; +// import tools.jackson.databind.ObjectMapper; +// import tools.jackson.databind.module.SimpleModule; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * LFJT3-aware tests for {@link CdsServiceRequestContextSerializer} and + * {@link CdsServiceRequestContextDeserializer}. + */ +class CdsServiceRequestContextSerializerDeserializerTest { + + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + private ObjectMapper myMapper; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + // Note: CdsServiceRequestContextSerializer/Deserializer take the mapper + // as a constructor arg — this mirrors CdsHooksObjectMapperFactory.newMapper() + ObjectMapper baseMapper = new ObjectMapper(); + // LFJT3: ObjectMapper baseMapper = JsonMapper.builder().build(); + + SimpleModule module = new SimpleModule(); + module.addSerializer(new CdsServiceRequestContextSerializer(FHIR_CONTEXT, baseMapper)); + module.addDeserializer( + CdsServiceRequestContextJson.class, + new CdsServiceRequestContextDeserializer(FHIR_CONTEXT, baseMapper)); + return baseMapper.registerModule(module); + // LFJT3: return JsonMapper.builder().addModule(module).build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Wrapper so we can embed CdsServiceRequestContextJson in an outer object + // ───────────────────────────────────────────────────────────────────────── + static class ContextHolder { + public CdsServiceRequestContextJson context; + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. CdsServiceRequestContextSerializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("CdsServiceRequestContextSerializer") + class SerializerTests { + + @Test + @DisplayName("Scalar string entry is serialized as a JSON string value") + void serialize_scalarString_isJsonString() throws Exception { + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patientId", "Patient/123"); + + String json = myMapper.writeValueAsString(context); + JsonNode root = myMapper.readTree(json); + + assertThat(root.get("patientId").asText()).isEqualTo("Patient/123"); + } + + @Test + @DisplayName("FHIR resource entry is embedded as JSON object, not quoted string") + void serialize_fhirResource_isJsonObject() throws Exception { + Patient patient = new Patient(); + patient.setId("pt-001"); + + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patient", patient); + + String json = myMapper.writeValueAsString(context); + JsonNode root = myMapper.readTree(json); + JsonNode patientNode = root.get("patient"); + + // Must be a JSON object, not a quoted/escaped string + assertNotNull(patientNode, "patient field must be present"); + assertTrue(patientNode.isObject(), + "patient must be embedded as a JSON object (writeRawValue), not a quoted string (writeString)"); + } + + @Test + @DisplayName("FHIR resource entry contains resourceType") + void serialize_fhirResource_hasResourceType() throws Exception { + Patient patient = new Patient(); + patient.setId("pt-002"); + + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patient", patient); + + String json = myMapper.writeValueAsString(context); + JsonNode patientNode = myMapper.readTree(json).get("patient"); + + assertThat(patientNode.get("resourceType").asText()).isEqualTo("Patient"); + } + + @Test + @DisplayName("Mixed context (scalar + FHIR resource) serializes both correctly") + void serialize_mixedContext() throws Exception { + Patient patient = new Patient(); + patient.setId("pt-003"); + + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("patientId", "Patient/123"); + context.put("patient", patient); + + String json = myMapper.writeValueAsString(context); + JsonNode root = myMapper.readTree(json); + + assertThat(root.get("patientId").asText()).isEqualTo("Patient/123"); + assertTrue(root.get("patient").isObject()); + } + + @Test + @DisplayName("Produces valid JSON (parseable without exception)") + void serialize_producesValidJson() throws Exception { + CdsServiceRequestContextJson context = new CdsServiceRequestContextJson(); + context.put("userId", "Practitioner/P1"); + + assertDoesNotThrow(() -> { + String json = myMapper.writeValueAsString(context); + myMapper.readTree(json); + }); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. CdsServiceRequestContextDeserializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("CdsServiceRequestContextDeserializer") + class DeserializerTests { + + @Test + @DisplayName("Scalar string entry is deserialized as String") + void deserialize_scalarString_isString() throws Exception { + String json = "{\"patientId\":\"Patient/123\"}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(context.get("patientId")).isEqualTo("Patient/123"); + } + + @Test + @DisplayName("FHIR resource entry (as nested map) is deserialized as IBaseResource") + void deserialize_fhirResourceMap_isIBaseResource() throws Exception { + String json = "{" + + "\"patient\":{" + + "\"resourceType\":\"Patient\"," + + "\"id\":\"pt-abc\"" + + "}" + + "}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + Object patient = context.get("patient"); + assertNotNull(patient); + assertThat(patient).isInstanceOf(org.hl7.fhir.instance.model.api.IBaseResource.class); + } + + @Test + @DisplayName("FHIR resource round-trip preserves patient ID") + void deserialize_fhirResource_preservesId() throws Exception { + String json = "{" + + "\"patient\":{" + + "\"resourceType\":\"Patient\"," + + "\"id\":\"pt-round-trip\"" + + "}" + + "}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + Patient patient = (Patient) context.get("patient"); + assertThat(patient.getIdElement().getIdPart()).isEqualTo("pt-round-trip"); + } + + @Test + @DisplayName("Mixed context (scalar + resource) deserializes both correctly") + void deserialize_mixedContext() throws Exception { + String json = "{" + + "\"userId\":\"Practitioner/P1\"," + + "\"patient\":{" + + "\"resourceType\":\"Patient\"," + + "\"id\":\"pt-mixed\"" + + "}" + + "}"; + + CdsServiceRequestContextJson context = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(context.get("userId")).isEqualTo("Practitioner/P1"); + assertThat(context.get("patient")).isInstanceOf(org.hl7.fhir.instance.model.api.IBaseResource.class); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Round-trip: serialize → deserialize + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip: serialize → deserialize") + class RoundTripTests { + + @Test + @DisplayName("FHIR Patient round-trip preserves resourceType and id") + void roundTrip_fhirPatient() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-1"); + + CdsServiceRequestContextJson originalContext = new CdsServiceRequestContextJson(); + originalContext.put("patient", original); + + String json = myMapper.writeValueAsString(originalContext); + CdsServiceRequestContextJson reparsed = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + Patient reparsedPatient = (Patient) reparsed.get("patient"); + assertThat(reparsedPatient.getIdElement().getIdPart()).isEqualTo("rt-patient-1"); + } + + @Test + @DisplayName("Scalar string round-trip preserves value") + void roundTrip_scalarString() throws Exception { + CdsServiceRequestContextJson originalContext = new CdsServiceRequestContextJson(); + originalContext.put("hookInstance", "uuid-999"); + + String json = myMapper.writeValueAsString(originalContext); + CdsServiceRequestContextJson reparsed = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(reparsed.get("hookInstance")).isEqualTo("uuid-999"); + } + + @Test + @DisplayName("Multiple keys all survive round-trip") + void roundTrip_multipleKeys() throws Exception { + Patient patient = new Patient(); + patient.setId("multi-pt"); + + CdsServiceRequestContextJson originalContext = new CdsServiceRequestContextJson(); + originalContext.put("patientId", "Patient/multi-pt"); + originalContext.put("userId", "Practitioner/dr-jones"); + originalContext.put("patient", patient); + + String json = myMapper.writeValueAsString(originalContext); + CdsServiceRequestContextJson reparsed = + myMapper.readValue(json, CdsServiceRequestContextJson.class); + + assertThat(reparsed.get("patientId")).isEqualTo("Patient/multi-pt"); + assertThat(reparsed.get("userId")).isEqualTo("Practitioner/dr-jones"); + assertThat(reparsed.get("patient")).isInstanceOf(org.hl7.fhir.instance.model.api.IBaseResource.class); + } + } +} diff --git a/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java new file mode 100644 index 000000000000..28b0281631f3 --- /dev/null +++ b/hapi-fhir-server-cds-hooks/src/test/java/ca/uhn/hapi/fhir/cdshooks/svc/CdsSvcJacksonTest.java @@ -0,0 +1,317 @@ +package ca.uhn.hapi.fhir.cdshooks.svc; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Contains LFJT3-aware Jackson 2 tests for: + * - BaseCdsMethod (Jackson: encodeRequest → writeValueAsString) + * - BaseDynamicCdsServiceMethod (no Jackson in the class itself — documented) + * - CdsConfigServiceImpl (no Jackson behavior — documented) + * - CdsServiceRegistryImpl (Jackson: buildResponseFromString, buildFeedbackFromString) + * + * Only the import block and createMapper() factory method change during LFJT3. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: com.fasterxml.jackson.* → tools.jackson.* + * [ ] createMapper(): new ObjectMapper() → JsonMapper.builder().build() + * [ ] BaseCdsMethod.encodeRequest(): catch (JsonProcessingException e) + * → catch (JacksonException e) — production class change only + * [ ] CdsServiceRegistryImpl.buildResponseFromString(): same catch swap + * [ ] CdsServiceRegistryImpl.buildFeedbackFromString(): same catch swap + */ + +import ca.uhn.fhir.context.ConfigurationException; +import ca.uhn.fhir.model.api.IModelJson; +import ca.uhn.fhir.rest.api.server.cdshooks.CdsServiceRequestJson; +import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceFeedbackJson; +import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceResponseCardJson; +import ca.uhn.hapi.fhir.cdshooks.api.json.CdsServiceResponseJson; +import com.fasterxml.jackson.annotation.JsonProperty; +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CdsSvcJacksonTest { + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + // ═════════════════════════════════════════════════════════════════════════ + // 1. BaseCdsMethod — Jackson: encodeRequest → writeValueAsString + // The Jackson-sensitive path: when the CDS service method parameter type + // is String, BaseCdsMethod.encodeRequest() serializes the IModelJson to + // a JSON string via ObjectMapper.writeValueAsString(). + // ═════════════════════════════════════════════════════════════════════════ + + /** Minimal IModelJson for testing. */ + static class TestRequestJson implements IModelJson { + @JsonProperty("hookInstance") + // @JsonProperty stays on com.fasterxml.jackson.annotation in Jackson 3 + private String hookInstance; + + TestRequestJson(String hookInstance) { this.hookInstance = hookInstance; } + public String getHookInstance() { return hookInstance; } + } + + /** Concrete service bean that accepts a String parameter. */ + static class StringParamService { + public String handle(String theRequest) { return theRequest; } + } + + /** Concrete service bean that accepts a typed parameter. */ + static class TypedParamService { + public Object handle(TestRequestJson theRequest) { return theRequest; } + } + + @Nested + @DisplayName("BaseCdsMethod — encodeRequest (String parameter path)") + class BaseCdsMethodTests { + + @Test + @DisplayName("When method param is String, IModelJson is serialized to JSON string") + void invoke_stringParam_serializesRequestToJson() throws Exception { + ObjectMapper mapper = createMapper(); + StringParamService bean = new StringParamService(); + Method method = StringParamService.class.getMethod("handle", String.class); + + // Use a concrete anonymous subclass since BaseCdsMethod is abstract + BaseCdsMethod svc = new BaseCdsMethod(bean, method) {}; + + TestRequestJson request = new TestRequestJson("uuid-001"); + Object result = svc.invoke(mapper, request, "test-service"); + + // The result should be a JSON string containing the hookInstance + assertThat(result).isInstanceOf(String.class); + assertThat((String) result).contains("uuid-001"); + } + + @Test + @DisplayName("Serialized string is valid JSON") + void invoke_stringParam_resultIsValidJson() throws Exception { + ObjectMapper mapper = createMapper(); + StringParamService bean = new StringParamService(); + Method method = StringParamService.class.getMethod("handle", String.class); + BaseCdsMethod svc = new BaseCdsMethod(bean, method) {}; + + TestRequestJson request = new TestRequestJson("uuid-002"); + String result = (String) svc.invoke(mapper, request, "test-service"); + + // Verify the string is parseable as JSON + assertDoesNotThrow(() -> mapper.readTree(result)); + } + + @Test + @DisplayName("When method param is typed (not String), IModelJson is passed directly") + void invoke_typedParam_passesRequestDirectly() throws Exception { + ObjectMapper mapper = createMapper(); + TypedParamService bean = new TypedParamService(); + Method method = TypedParamService.class.getMethod("handle", TestRequestJson.class); + BaseCdsMethod svc = new BaseCdsMethod(bean, method) {}; + + TestRequestJson request = new TestRequestJson("uuid-typed"); + Object result = svc.invoke(mapper, request, "test-service"); + + // Passed directly — not serialized to String + assertThat(result).isSameAs(request); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // 2. BaseDynamicCdsServiceMethod + // No Jackson imports in the class itself — the Function<> does the work. + // LFJT3 NOTE: No changes required in BaseDynamicCdsServiceMethod.java + // for the LFJT3 uplift. The ObjectMapper parameter is typed as + // com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + // but the class logic doesn't call any Jackson API directly. + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("BaseDynamicCdsServiceMethod — function invocation") + class BaseDynamicCdsServiceMethodTests { + + @Test + @DisplayName("Registered function is invoked and returns response") + void invoke_delegatesToFunction() throws Exception { + ObjectMapper mapper = createMapper(); + + CdsServiceResponseJson expectedResponse = new CdsServiceResponseJson(); + CdsServiceResponseCardJson card = new CdsServiceResponseCardJson(); + card.setSummary("Dynamic Response"); + expectedResponse.addCard(card); + + BaseDynamicCdsServiceMethod svc = + new BaseDynamicCdsServiceMethod(req -> expectedResponse) {}; + + CdsServiceRequestJson request = new CdsServiceRequestJson(); + Object result = svc.invoke(mapper, request, "dynamic-service"); + + assertThat(result).isSameAs(expectedResponse); + } + + @Test + @DisplayName("getFunction returns the registered function") + void getFunction_returnsRegisteredFunction() { + java.util.function.Function fn = + req -> new CdsServiceResponseJson(); + + BaseDynamicCdsServiceMethod svc = new BaseDynamicCdsServiceMethod(fn) {}; + + assertThat(svc.getFunction()).isSameAs(fn); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // 3. CdsConfigServiceImpl + // Pure bean — stores ObjectMapper reference, no Jackson API calls. + // LFJT3 NOTE: Only import changes (ObjectMapper package rename). + // No behavior changes needed. + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("CdsConfigServiceImpl — ObjectMapper delegation") + class CdsConfigServiceImplTests { + + @Test + @DisplayName("getObjectMapper returns the mapper passed to the constructor") + void getObjectMapper_returnsConstructorArg() { + ObjectMapper mapper = createMapper(); + CdsConfigServiceImpl svc = new CdsConfigServiceImpl( + ca.uhn.fhir.context.FhirContext.forR4(), mapper, null, null); + + assertThat(svc.getObjectMapper()).isSameAs(mapper); + } + + @Test + @DisplayName("getFhirContext returns the context passed to the constructor") + void getFhirContext_returnsConstructorArg() { + ca.uhn.fhir.context.FhirContext ctx = ca.uhn.fhir.context.FhirContext.forR4(); + CdsConfigServiceImpl svc = new CdsConfigServiceImpl(ctx, createMapper(), null, null); + + assertThat(svc.getFhirContext()).isSameAs(ctx); + } + } + + // ═════════════════════════════════════════════════════════════════════════ + // 4. CdsServiceRegistryImpl — Jackson paths: + // encodeServiceResponse(String) → ObjectMapper.readValue → CdsServiceResponseJson + // encodeFeedbackResponse(String) → ObjectMapper.readValue → CdsServiceFeedbackJson + // ═════════════════════════════════════════════════════════════════════════ + + @Nested + @DisplayName("CdsServiceRegistryImpl — Jackson encode paths") + class CdsServiceRegistryImplJacksonTests { + + private CdsServiceRegistryImpl buildRegistryImpl() { + ObjectMapper mapper = createMapper(); + CdsHooksContextBooter booter = mock(CdsHooksContextBooter.class); + when(booter.buildCdsServiceCache()).thenReturn(new CdsServiceCache()); + + ca.uhn.hapi.fhir.cdshooks.svc.prefetch.CdsPrefetchSvc prefetchSvc = + mock(ca.uhn.hapi.fhir.cdshooks.svc.prefetch.CdsPrefetchSvc.class); + + ca.uhn.hapi.fhir.cdshooks.serializer.CdsServiceRequestJsonDeserializer deserializer = + mock(ca.uhn.hapi.fhir.cdshooks.serializer.CdsServiceRequestJsonDeserializer.class); + + CdsServiceRegistryImpl registry = new CdsServiceRegistryImpl( + booter, prefetchSvc, mapper, deserializer); + registry.init(); + return registry; + } + + @Test + @DisplayName("encodeServiceResponse: String result is deserialized to CdsServiceResponseJson") + void encodeServiceResponse_stringResult_returnsDeserializedObject() throws Exception { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + ObjectMapper mapper = createMapper(); + + CdsServiceResponseJson expected = new CdsServiceResponseJson(); + CdsServiceResponseCardJson card = new CdsServiceResponseCardJson(); + card.setSummary("Test Card"); + expected.addCard(card); + + // Serialize the response as it would come back from a String-returning service method + String jsonResponse = mapper.writeValueAsString(expected); + + CdsServiceResponseJson result = registry.encodeServiceResponse("test-svc", jsonResponse); + + assertNotNull(result); + assertThat(result.getCards()).hasSize(1); + assertThat(result.getCards().get(0).getSummary()).isEqualTo("Test Card"); + } + + @Test + @DisplayName("encodeServiceResponse: CdsServiceResponseJson result is returned directly (no Jackson)") + void encodeServiceResponse_typedResult_returnedDirectly() { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + + CdsServiceResponseJson response = new CdsServiceResponseJson(); + CdsServiceResponseCardJson card = new CdsServiceResponseCardJson(); + card.setSummary("Direct"); + response.addCard(card); + + CdsServiceResponseJson result = registry.encodeServiceResponse("test-svc", response); + + assertThat(result).isSameAs(response); + } + + @Test + @DisplayName("encodeServiceResponse: invalid JSON string throws ConfigurationException") + void encodeServiceResponse_invalidJson_throwsConfigurationException() { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + + assertThrows(ConfigurationException.class, + () -> registry.encodeServiceResponse("test-svc", "not valid json")); + } + + @Test + @DisplayName("encodeFeedbackResponse: String result is deserialized to CdsServiceFeedbackJson") + void encodeFeedbackResponse_stringResult_returnsDeserializedObject() throws Exception { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + ObjectMapper mapper = createMapper(); + + CdsServiceFeedbackJson expected = new CdsServiceFeedbackJson(); + expected.setCard("card-uuid-123"); + + String jsonResponse = mapper.writeValueAsString(expected); + + CdsServiceFeedbackJson result = registry.encodeFeedbackResponse("test-svc", jsonResponse); + + assertNotNull(result); + assertThat(result.getCard()).isEqualTo("card-uuid-123"); + } + + @Test + @DisplayName("encodeFeedbackResponse: invalid JSON string throws RuntimeException") + void encodeFeedbackResponse_invalidJson_throwsRuntimeException() { + CdsServiceRegistryImpl registry = buildRegistryImpl(); + + assertThrows(RuntimeException.class, + () -> registry.encodeFeedbackResponse("test-svc", "{bad json}")); + } + } +} diff --git a/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java new file mode 100644 index 000000000000..e616fd1543b2 --- /dev/null +++ b/hapi-fhir-server/src/test/java/ca/uhn/fhir/rest/server/util/JsonDateSerializerDeserializerTest.java @@ -0,0 +1,257 @@ +package ca.uhn.fhir.rest.server.util; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Structured so that only + * the two clearly marked sections change during the future LFJT3 uplift: + * + * 1. "── LFJT3 JACKSON IMPORT BLOCK ──" + * 2. "── LFJT3 MAPPER FACTORY ──" + * + * All @Test methods are Jackson-version-agnostic and need zero changes. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: + * com.fasterxml.jackson.databind.JsonDeserializer → tools.jackson.databind.ValueDeserializer + * com.fasterxml.jackson.databind.JsonSerializer → tools.jackson.databind.ValueSerializer + * com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + * com.fasterxml.jackson.databind.module.SimpleModule → tools.jackson.databind.module.SimpleModule + * [ ] createMapper() factory: new ObjectMapper() → JsonMapper.builder().build() + * [ ] JsonDateDeserializer.deserialize() loses "throws IOException" in Jackson 3 — + * but that change is in the production class, not here. + * [ ] JsonDateSerializer.serialize() loses "throws IOException" in Jackson 3 — + * same — production class change only. + */ + +import ca.uhn.fhir.model.primitive.DateTimeDt; +import ca.uhn.fhir.model.primitive.InstantDt; + +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW — do not change yet): +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// Jackson 3 (LFJT3 — swap the three lines above for): +// import com.fasterxml.jackson.annotation.JsonProperty; // stays — annotations never move +// import tools.jackson.databind.ObjectMapper; +// import tools.jackson.databind.module.SimpleModule; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * LFJT3-aware unit tests for {@link JsonDateDeserializer} and {@link JsonDateSerializer}. + * + *

Tests run under Jackson 2. The {@link #createMapper()} factory method is the + * single change point for LFJT3 — see the migration checklist in the file header. + */ +class JsonDateSerializerDeserializerTest { + + private ObjectMapper myMapper; + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Date.class, new JsonDateSerializer()); + module.addDeserializer(Date.class, new JsonDateDeserializer()); + return new ObjectMapper().registerModule(module); + // LFJT3: return JsonMapper.builder().addModule(module).build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Wrapper for round-trip tests — uses @JsonProperty (stays com.fasterxml in Jackson 3) + // ───────────────────────────────────────────────────────────────────────── + static class DateHolder { + @JsonProperty("date") + public Date date; + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. JsonDateSerializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("JsonDateSerializer") + class SerializerTests { + + @Test + @DisplayName("Serializes Date as ISO-8601 string") + void serialize_producesIso8601String() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = new InstantDt("2024-06-01T10:00:00Z").getValue(); + + String json = myMapper.writeValueAsString(holder); + + // Value must be a quoted string, not a number + assertThat(json).contains("\"date\""); + assertThat(json).contains("2024-06-01"); + } + + @Test + @DisplayName("Serializes null Date without error") + void serialize_nullDate_producesNullJson() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = null; + + String json = assertDoesNotThrow(() -> myMapper.writeValueAsString(holder)); + // null value → null in JSON + assertThat(json).contains("null"); + } + + @Test + @DisplayName("Serialized value is parseable by HAPI InstantDt") + void serialize_producesValueParseableByHapi() throws Exception { + Date original = new InstantDt("2024-03-15T14:30:00Z").getValue(); + DateHolder holder = new DateHolder(); + holder.date = original; + + String json = myMapper.writeValueAsString(holder); + + // Extract the string value between the quotes after "date": + // Use Jackson to read it back as a raw string + String dateValue = myMapper.readTree(json).get("date").asText(); + + // Verify HAPI can parse the output + assertDoesNotThrow(() -> new InstantDt(dateValue), + "Serialized value must be parseable by HAPI InstantDt"); + } + + @Test + @DisplayName("Serialized output is not an epoch-millis number") + void serialize_isNotEpochMillis() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = new Date(); + + String json = myMapper.writeValueAsString(holder); + String dateNode = myMapper.readTree(json).get("date").toString(); + + // Must start with a quote (string), not a digit (epoch) + assertThat(dateNode).startsWith("\""); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. JsonDateDeserializer + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("JsonDateDeserializer") + class DeserializerTests { + + @Test + @DisplayName("Deserializes ISO-8601 string to Date") + void deserialize_iso8601String_returnsDate() throws Exception { + String json = "{\"date\":\"2024-06-01T10:00:00.000Z\"}"; + + DateHolder holder = myMapper.readValue(json, DateHolder.class); + + assertNotNull(holder.date, "Deserialized Date must not be null"); + // Verify round-trip value matches the HAPI-parsed date + Date expected = new DateTimeDt("2024-06-01T10:00:00.000Z").getValue(); + assertThat(holder.date).isEqualTo(expected); + } + + @Test + @DisplayName("Deserializes null JSON value to null Date") + void deserialize_nullJson_returnsNull() throws Exception { + String json = "{\"date\":null}"; + DateHolder holder = myMapper.readValue(json, DateHolder.class); + assertNull(holder.date, "null JSON value must deserialize to null Date"); + } + + @Test + @DisplayName("Deserializes date-only string (no time component)") + void deserialize_dateOnlyString_returnsDate() throws Exception { + String json = "{\"date\":\"2024-06-01\"}"; + DateHolder holder = myMapper.readValue(json, DateHolder.class); + assertNotNull(holder.date); + } + + @Test + @DisplayName("Deserializes empty string to null") + void deserialize_emptyString_returnsNull() throws Exception { + String json = "{\"date\":\"\"}"; + DateHolder holder = myMapper.readValue(json, DateHolder.class); + assertNull(holder.date, + "Empty string must deserialize to null (matches isNotBlank check in production code)"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Round-trip: Serialize then Deserialize + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip: serialize → deserialize") + class RoundTripTests { + + @Test + @DisplayName("Date survives full round-trip with second precision") + void roundTrip_secondPrecision() throws Exception { + Date original = new InstantDt("2024-06-15T08:30:00Z").getValue(); + DateHolder holder = new DateHolder(); + holder.date = original; + + String json = myMapper.writeValueAsString(holder); + DateHolder reparsed = myMapper.readValue(json, DateHolder.class); + + assertNotNull(reparsed.date); + // Compare as strings via HAPI to avoid millisecond rounding differences + String originalStr = new InstantDt(original).getValueAsString(); + String reparsedStr = new InstantDt(reparsed.date).getValueAsString(); + assertThat(reparsedStr).isEqualTo(originalStr); + } + + @Test + @DisplayName("Null Date survives full round-trip") + void roundTrip_nullDate() throws Exception { + DateHolder holder = new DateHolder(); + holder.date = null; + + String json = myMapper.writeValueAsString(holder); + DateHolder reparsed = myMapper.readValue(json, DateHolder.class); + + assertNull(reparsed.date); + } + + @Test + @DisplayName("Multiple different dates round-trip independently") + void roundTrip_multipleDates() throws Exception { + String[] isoStrings = { + "2020-01-01T00:00:00Z", + "2024-12-31T23:59:59Z", + "2000-06-15T12:00:00Z" + }; + + for (String iso : isoStrings) { + Date original = new InstantDt(iso).getValue(); + DateHolder holder = new DateHolder(); + holder.date = original; + + String json = myMapper.writeValueAsString(holder); + DateHolder reparsed = myMapper.readValue(json, DateHolder.class); + + assertThat(new InstantDt(reparsed.date).getValueAsString()) + .as("Round-trip failed for: " + iso) + .isEqualTo(new InstantDt(original).getValueAsString()); + } + } + } +} diff --git a/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java b/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java new file mode 100644 index 000000000000..3690d81c44a9 --- /dev/null +++ b/hapi-fhir-storage/src/test/java/ca/uhn/fhir/jpa/binary/api/StoredDetailsTest.java @@ -0,0 +1,331 @@ +package ca.uhn.fhir.jpa.binary.api; + +/* + * LFJT3 — "Looking Forward to Jackson Tools 3" + * ============================================= + * Written for Jackson 2 (com.fasterxml.jackson). Structured so that only + * the two clearly marked sections change during the future LFJT3 uplift: + * + * 1. "── LFJT3 JACKSON IMPORT BLOCK ──" + * 2. "── LFJT3 MAPPER FACTORY ──" + * + * All @Test methods are Jackson-version-agnostic and need zero changes. + * + * LFJT3 MIGRATION CHECKLIST + * -------------------------- + * [ ] Imports: + * com.fasterxml.jackson.databind.ObjectMapper → tools.jackson.databind.ObjectMapper + * com.fasterxml.jackson.databind.JsonNode → tools.jackson.databind.JsonNode + * NOTE: com.fasterxml.jackson.annotation.* stays unchanged in Jackson 3 + * (@JsonProperty, @JsonSerialize, @JsonDeserialize on the production class) + * BUT @JsonSerialize/@JsonDeserialize are in com.fasterxml.jackson.databind.annotation + * and THOSE move to tools.jackson.databind.annotation (production class change) + * [ ] createMapper() factory: new ObjectMapper() → JsonMapper.builder().build() + * + * KEY CONTRACT THIS TEST LOCKS DOWN + * ---------------------------------- + * StoredDetails uses @JsonSerialize(using=JsonDateSerializer.class) and + * @JsonDeserialize(using=JsonDateDeserializer.class) on the `published` field. + * After LFJT3 uplift, that annotation package moves but the round-trip behavior + * must remain identical. These tests lock down that behavior. + */ + +// ── LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────────── +// Jackson 2 (NOW): +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +// Jackson 3 (LFJT3): +// import tools.jackson.databind.JsonNode; +// import tools.jackson.databind.ObjectMapper; +// ── END LFJT3 JACKSON IMPORT BLOCK ─────────────────────────────────────────── + +import com.google.common.hash.Hashing; +import com.google.common.hash.HashingInputStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * LFJT3-aware unit tests for {@link StoredDetails}. + * + *

Focuses on Jackson serialization/deserialization behavior — particularly + * the custom Date serializer/deserializer on the {@code published} field, and + * the legacy {@code blobId} → {@code binaryContentId} backward-compat field. + */ +class StoredDetailsTest { + + // ── LFJT3 MAPPER FACTORY ───────────────────────────────────────────────── + // Jackson 2 (NOW): new ObjectMapper() + // Jackson 3 (LFJT3): JsonMapper.builder().build() + private ObjectMapper createMapper() { + return new ObjectMapper(); + // LFJT3: return JsonMapper.builder().build(); + } + // ── END LFJT3 MAPPER FACTORY ───────────────────────────────────────────── + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private StoredDetails buildStoredDetails(String binaryContentId, String contentType, Date published) + throws IOException { + byte[] data = "test-binary-data".getBytes(StandardCharsets.UTF_8); + HashingInputStream his = new HashingInputStream( + Hashing.sha256(), new ByteArrayInputStream(data)); + IOUtils.consume(his); + return new StoredDetails(binaryContentId, data.length, contentType, his, published); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. Basic serialization structure + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Serialization structure") + class SerializationStructureTests { + + @Test + @DisplayName("Serialized JSON contains expected top-level keys") + void serialize_containsExpectedKeys() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("binary-001", "application/pdf", new Date()); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.has("binaryContentId")).isTrue(); + assertThat(root.has("bytes")).isTrue(); + assertThat(root.has("contentType")).isTrue(); + assertThat(root.has("hash")).isTrue(); + assertThat(root.has("published")).isTrue(); + } + + @Test + @DisplayName("binaryContentId is serialized correctly") + void serialize_binaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("my-binary-id", "image/png", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.get("binaryContentId").asText()).isEqualTo("my-binary-id"); + } + + @Test + @DisplayName("bytes field contains the correct byte count") + void serialize_bytesField() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "text/plain", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + // "test-binary-data" is 16 bytes + assertThat(root.get("bytes").asLong()).isEqualTo(16L); + } + + @Test + @DisplayName("hash field is non-empty after construction") + void serialize_hashFieldPresent() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "text/plain", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.get("hash").asText()).isNotBlank(); + } + + @Test + @DisplayName("contentType field is serialized correctly") + void serialize_contentType() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "application/fhir+json", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + + assertThat(root.get("contentType").asText()).isEqualTo("application/fhir+json"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. Published date — custom @JsonSerialize/@JsonDeserialize + // This is the primary LFJT3-sensitive behavior: the annotation package + // moves from com.fasterxml.jackson.databind.annotation to + // tools.jackson.databind.annotation but behavior must remain identical. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Published date — custom serializer/deserializer") + class PublishedDateTests { + + @Test + @DisplayName("Null published date serializes to null or is absent") + void serialize_nullPublished_isNullOrAbsent() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-1", "text/plain", null); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + JsonNode publishedNode = root.path("published"); // path() never returns null + + // Jackson may omit null fields entirely OR write them as JSON null. + // Both are valid — the contract is: no non-null date value is present. + assertThat(publishedNode.isNull() || publishedNode.isMissingNode()) + .as("null published date must serialize to JSON null or be omitted entirely") + .isTrue(); + } + + @Test + @DisplayName("Published date serializes as an ISO-8601 string (not epoch millis)") + void serialize_publishedDate_isIsoString() throws Exception { + ObjectMapper mapper = createMapper(); + Date published = new Date(1717200000000L); // 2024-06-01 + StoredDetails details = buildStoredDetails("id-1", "text/plain", published); + + JsonNode root = mapper.readTree(mapper.writeValueAsString(details)); + JsonNode publishedNode = root.get("published"); + + // Must be a text node (ISO string), NOT a number (epoch millis) + assertThat(publishedNode.isTextual()) + .as("published must be an ISO-8601 string, not epoch millis") + .isTrue(); + assertThat(publishedNode.asText()).contains("2024"); + } + + @Test + @DisplayName("Published date round-trips correctly through serialize/deserialize") + void roundTrip_publishedDate() throws Exception { + ObjectMapper mapper = createMapper(); + Date original = new Date(1717200000000L); + StoredDetails details = buildStoredDetails("id-1", "text/plain", original); + + String json = mapper.writeValueAsString(details); + StoredDetails reparsed = mapper.readValue(json, StoredDetails.class); + + assertNotNull(reparsed.getPublished()); + // Compare at second granularity via the serialized string to avoid + // sub-second rounding differences between Jackson 2 and Jackson 3 + String jsonOriginal = mapper.readTree(json).get("published").asText(); + String jsonReparsed = mapper.readTree(mapper.writeValueAsString(reparsed)) + .get("published").asText(); + assertThat(jsonReparsed).isEqualTo(jsonOriginal); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. Legacy blobId backward-compat field + // StoredDetails has a blobId field to support pre-7.2.0 stored data. + // Deserializing old JSON that uses "blobId" must return that value + // via getBinaryContentId(). + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Legacy blobId backward-compatibility") + class LegacyBlobIdTests { + + @Test + @DisplayName("JSON with blobId field deserializes and getBinaryContentId returns it") + void deserialize_legacyBlobId_returnedByGetBinaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + // This is the old JSON format (pre 7.2.0) — uses "blobId" not "binaryContentId" + String legacyJson = "{" + + "\"blobId\":\"old-blob-id-123\"," + + "\"bytes\":100," + + "\"contentType\":\"application/pdf\"," + + "\"hash\":\"abc123\"" + + "}"; + + StoredDetails details = mapper.readValue(legacyJson, StoredDetails.class); + + // getBinaryContentId() must fall back to blobId when binaryContentId is null + assertThat(details.getBinaryContentId()).isEqualTo("old-blob-id-123"); + } + + @Test + @DisplayName("JSON with both binaryContentId and blobId prefers binaryContentId") + void deserialize_bothFields_prefersBinaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + String json = "{" + + "\"binaryContentId\":\"new-id\"," + + "\"blobId\":\"old-id\"," + + "\"bytes\":100," + + "\"contentType\":\"text/plain\"," + + "\"hash\":\"xyz\"" + + "}"; + + StoredDetails details = mapper.readValue(json, StoredDetails.class); + + // binaryContentId is set, so it wins over blobId + assertThat(details.getBinaryContentId()).isEqualTo("new-id"); + } + + @Test + @DisplayName("JSON with only binaryContentId (new format) deserializes correctly") + void deserialize_newFormat_binaryContentId() throws Exception { + ObjectMapper mapper = createMapper(); + String json = "{" + + "\"binaryContentId\":\"binary-456\"," + + "\"bytes\":200," + + "\"contentType\":\"image/png\"," + + "\"hash\":\"hashval\"" + + "}"; + + StoredDetails details = mapper.readValue(json, StoredDetails.class); + + assertThat(details.getBinaryContentId()).isEqualTo("binary-456"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. Full round-trip + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Full round-trip") + class RoundTripTests { + + @Test + @DisplayName("All fields survive serialize → deserialize") + void roundTrip_allFields() throws Exception { + ObjectMapper mapper = createMapper(); + Date published = new Date(1717200000000L); + StoredDetails original = buildStoredDetails("rt-id-001", "application/fhir+json", published); + + String json = mapper.writeValueAsString(original); + StoredDetails reparsed = mapper.readValue(json, StoredDetails.class); + + assertThat(reparsed.getBinaryContentId()).isEqualTo("rt-id-001"); + assertThat(reparsed.getContentType()).isEqualTo("application/fhir+json"); + assertThat(reparsed.getBytes()).isEqualTo(original.getBytes()); + assertThat(reparsed.getHash()).isEqualTo(original.getHash()); + assertNotNull(reparsed.getPublished()); + } + + @Test + @DisplayName("Serialized JSON is valid (parseable without exception)") + void roundTrip_isValidJson() throws Exception { + ObjectMapper mapper = createMapper(); + StoredDetails details = buildStoredDetails("id-valid", "text/plain", new Date()); + + assertDoesNotThrow(() -> { + String json = mapper.writeValueAsString(details); + mapper.readTree(json); + }); + } + + @Test + @DisplayName("Default constructor produces deserializable empty instance") + void defaultConstructor_isDeserializable() throws Exception { + ObjectMapper mapper = createMapper(); + String json = "{}"; + StoredDetails details = assertDoesNotThrow( + () -> mapper.readValue(json, StoredDetails.class)); + assertNotNull(details); + } + } +} diff --git a/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java new file mode 100644 index 000000000000..4e796d6e4611 --- /dev/null +++ b/hapi-fhir-structures-r4/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java @@ -0,0 +1,361 @@ +package ca.uhn.fhir.serializer; + +/* + * FhirResourceSerializerTest + * + * PURPOSE + * ------- + * These tests lock down the behaviour of FhirResourceSerializer under Jackson 2 + * (com.fasterxml.jackson) so that the same tests can be re-run — without + * modification to the test logic — after the Jackson 3 (tools.jackson) uplift. + * + * JACKSON 3 MIGRATION GUIDE FOR THIS FILE + * ---------------------------------------- + * When upgrading to jackson-tools-3, change ONLY the clearly marked + * "── JACKSON IMPORT BLOCK ──" section below. Test logic is untouched. + * + * Jackson 2 → Jackson 3 import swaps: + * + * com.fasterxml.jackson.databind.ObjectMapper + * → tools.jackson.databind.ObjectMapper + * + * com.fasterxml.jackson.databind.JsonNode + * → tools.jackson.databind.JsonNode + * + * com.fasterxml.jackson.databind.module.SimpleModule + * → tools.jackson.databind.module.SimpleModule + * + * com.fasterxml.jackson.databind.json.JsonMapper (Jackson 3 only — replaces new ObjectMapper()) + * → tools.jackson.databind.json.JsonMapper + * + * createMapper() helper below is the single place that constructs ObjectMapper. + * In Jackson 3, change: + * new ObjectMapper() → JsonMapper.builder().build() + * Everything else stays the same. + * + * NOTE: StdSerializer also moves: + * com.fasterxml.jackson.databind.ser.std.StdSerializer + * → tools.jackson.databind.ser.std.StdSerializer + * That change lives in FhirResourceSerializer.java itself, not here. + */ + +import ca.uhn.fhir.context.FhirContext; + +// ── JACKSON IMPORT BLOCK ───────────────────────────────────────────────────── +// ONLY this block changes during the Jackson 3 uplift. See migration guide above. +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// ── END JACKSON IMPORT BLOCK ────────────────────────────────────────────────── + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Observation; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link FhirResourceSerializer}. + * + *

Tests are written against Jackson 2 ({@code com.fasterxml.jackson}) but are + * deliberately structured so that only the import block above and the + * {@link #createMapper(FhirContext)} factory method need to change when migrating + * to Jackson 3 ({@code tools.jackson}). All assertions operate on plain + * {@code String} and {@code JsonNode} so they are Jackson-version-agnostic. + */ +class FhirResourceSerializerTest { + + // ── Shared FHIR context (expensive to construct — reuse across tests) ──── + private static final FhirContext FHIR_CONTEXT = FhirContext.forR4(); + + private ObjectMapper myMapper; + + // ── JACKSON FACTORY METHOD ─────────────────────────────────────────────── + // Jackson 3 change: replace new ObjectMapper() + // with JsonMapper.builder().build() + // Everything else in this method stays the same. + private ObjectMapper createMapper(FhirContext theFhirContext) { + SimpleModule module = new SimpleModule(); + module.addSerializer(new FhirResourceSerializer(theFhirContext)); + ObjectMapper mapper = new ObjectMapper(); // Jackson 3: JsonMapper.builder().build() + mapper.registerModule(module); + return mapper; + } + // ── END JACKSON FACTORY METHOD ─────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(FHIR_CONTEXT); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. OUTPUT FORMAT — the most critical behavioural contract: + // writeRawValue() must be used, not writeString(). + // If writeString() were used the FHIR JSON would appear as an escaped + // string value in the parent JSON, breaking all consumers. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Output format — raw embedded JSON, not escaped string") + class OutputFormatTests { + + @Test + @DisplayName("FHIR resource is embedded as a JSON object, not a quoted string") + void serialize_embedsFhirResourceAsJsonObject_notQuotedString() throws Exception { + // Given + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + // When + String json = myMapper.writeValueAsString(wrapper); + + // Then — the value of the "resource" field must be a JSON object {…}, + // NOT a quoted string "…". If writeString() were called instead of + // writeRawValue() this assertion would fail. + JsonNode root = myMapper.readTree(json); + JsonNode resourceNode = root.get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), + "'resource' field must be a JSON object, was: " + resourceNode.getNodeType()); + } + + @Test + @DisplayName("Serialized output is valid JSON (parseable without error)") + void serialize_producesValidJson() { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + assertDoesNotThrow(() -> { + String json = myMapper.writeValueAsString(wrapper); + myMapper.readTree(json); // throws if invalid JSON + }, "Serialized output must be valid JSON"); + } + + @Test + @DisplayName("Embedded FHIR JSON contains resourceType field") + void serialize_embeddedJsonContainsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("resourceType")) + .as("Embedded FHIR JSON must contain 'resourceType'") + .isTrue(); + assertThat(resourceNode.get("resourceType").asText()) + .isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. PRETTY PRINT — parser is constructed with setPrettyPrint(true) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Pretty print — parser constructed with setPrettyPrint(true)") + class PrettyPrintTests { + + @Test + @DisplayName("Embedded FHIR JSON contains newlines (pretty-printed)") + void serialize_embeddedFhirJsonIsPrettyPrinted() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + + // Extract the raw embedded FHIR fragment — it should contain newlines + // because the parser is created with setPrettyPrint(true). + // We check for \n in the overall output since the FHIR block is inlined. + assertThat(json) + .as("Serialized output should contain newlines from pretty-printed FHIR JSON") + .contains("\n"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. RESOURCE TYPE COVERAGE — serializer handles IBaseResource subtypes + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Resource type coverage") + class ResourceTypeCoverageTests { + + @Test + @DisplayName("Serializes Patient resource") + void serialize_patient() throws Exception { + Patient patient = buildMinimalPatient(); + assertSerializesWithResourceType(patient, "Patient"); + } + + @Test + @DisplayName("Serializes Patient with demographic fields") + void serialize_patientWithDemographics() throws Exception { + Patient patient = new Patient(); + patient.setId("patient-demo-1"); + patient.setGender(Enumerations.AdministrativeGender.FEMALE); + patient.addName().setFamily("Smith").addGiven("Jane"); + patient.addAddress().setCity("Boston").setState("MA").setPostalCode("02101"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.get("resourceType").asText()).isEqualTo("Patient"); + assertThat(resourceNode.has("gender")).isTrue(); + assertThat(resourceNode.get("gender").asText()).isEqualTo("female"); + assertThat(resourceNode.has("name")).isTrue(); + assertThat(resourceNode.has("address")).isTrue(); + } + + @Test + @DisplayName("Serializes Observation resource") + void serialize_observation() throws Exception { + Observation observation = new Observation(); + observation.setId("obs-1"); + observation.setStatus(Observation.ObservationStatus.FINAL); + assertSerializesWithResourceType(observation, "Observation"); + } + + @Test + @DisplayName("Serializes resource with ID preserved") + void serialize_preservesResourceId() throws Exception { + Patient patient = new Patient(); + patient.setId("test-patient-id-999"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("id")).isTrue(); + assertThat(resourceNode.get("id").asText()).isEqualTo("test-patient-id-999"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. DIRECT SERIALIZATION — serialize the resource as the root value + // (not wrapped in an outer object) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Direct serialization — resource as root value") + class DirectSerializationTests { + + @Test + @DisplayName("Resource serialized directly to String is valid JSON") + void serialize_directlyToString_isValidJson() throws Exception { + Patient patient = buildMinimalPatient(); + + // Serialize directly (no wrapper object) + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + String json = writer.toString(); + + assertDoesNotThrow(() -> myMapper.readTree(json), + "Direct serialization output must be valid JSON"); + } + + @Test + @DisplayName("Resource serialized directly contains resourceType") + void serialize_directlyToString_containsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + + JsonNode root = myMapper.readTree(writer.toString()); + assertThat(root.get("resourceType").asText()).isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 5. ROUND-TRIP — serialize then re-parse the embedded FHIR JSON + // back into a FHIR resource and verify field integrity + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip — serialize → re-parse embedded FHIR JSON") + class RoundTripTests { + + @Test + @DisplayName("Round-trip Patient preserves family name") + void roundTrip_patientFamilyName() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-1"); + original.addName().setFamily("Johnson").addGiven("Robert"); + + // Serialize + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + // Re-parse the embedded FHIR JSON back into a Patient + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getNameFirstRep().getFamily()) + .isEqualTo("Johnson"); + assertThat(reparsed.getNameFirstRep().getGivenAsSingleString()) + .isEqualTo("Robert"); + } + + @Test + @DisplayName("Round-trip Patient preserves gender") + void roundTrip_patientGender() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-2"); + original.setGender(Enumerations.AdministrativeGender.MALE); + + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getGender()) + .isEqualTo(Enumerations.AdministrativeGender.MALE); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static Patient buildMinimalPatient() { + Patient patient = new Patient(); + patient.setId("minimal-patient-1"); + return patient; + } + + private void assertSerializesWithResourceType(IBaseResource theResource, String theExpectedType) + throws Exception { + String json = myMapper.writeValueAsString(new Wrapper(theResource)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), "'resource' must be a JSON object"); + assertThat(resourceNode.get("resourceType").asText()).isEqualTo(theExpectedType); + } + + /** + * Simple wrapper to produce an outer JSON object with a single "resource" + * field — this lets us verify that the serializer writes a JSON object + * value rather than a JSON string value (i.e. writeRawValue vs writeString). + */ + static class Wrapper { + private final IBaseResource myResource; + + Wrapper(IBaseResource theResource) { + myResource = theResource; + } + + public IBaseResource getResource() { + return myResource; + } + } +} diff --git a/hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java b/hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java new file mode 100644 index 000000000000..8ea7bc706627 --- /dev/null +++ b/hapi-fhir-structures-r5/src/test/java/ca/uhn/fhir/serializer/FhirResourceSerializerTest.java @@ -0,0 +1,361 @@ +package ca.uhn.fhir.serializer; + +/* + * FhirResourceSerializerTest + * + * PURPOSE + * ------- + * These tests lock down the behaviour of FhirResourceSerializer under Jackson 2 + * (com.fasterxml.jackson) so that the same tests can be re-run — without + * modification to the test logic — after the Jackson 3 (tools.jackson) uplift. + * + * JACKSON 3 MIGRATION GUIDE FOR THIS FILE + * ---------------------------------------- + * When upgrading to jackson-tools-3, change ONLY the clearly marked + * "── JACKSON IMPORT BLOCK ──" section below. Test logic is untouched. + * + * Jackson 2 → Jackson 3 import swaps: + * + * com.fasterxml.jackson.databind.ObjectMapper + * → tools.jackson.databind.ObjectMapper + * + * com.fasterxml.jackson.databind.JsonNode + * → tools.jackson.databind.JsonNode + * + * com.fasterxml.jackson.databind.module.SimpleModule + * → tools.jackson.databind.module.SimpleModule + * + * com.fasterxml.jackson.databind.json.JsonMapper (Jackson 3 only — replaces new ObjectMapper()) + * → tools.jackson.databind.json.JsonMapper + * + * createMapper() helper below is the single place that constructs ObjectMapper. + * In Jackson 3, change: + * new ObjectMapper() → JsonMapper.builder().build() + * Everything else stays the same. + * + * NOTE: StdSerializer also moves: + * com.fasterxml.jackson.databind.ser.std.StdSerializer + * → tools.jackson.databind.ser.std.StdSerializer + * That change lives in FhirResourceSerializer.java itself, not here. + */ + +import ca.uhn.fhir.context.FhirContext; + +// ── JACKSON IMPORT BLOCK ───────────────────────────────────────────────────── +// ONLY this block changes during the Jackson 3 uplift. See migration guide above. +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +// ── END JACKSON IMPORT BLOCK ────────────────────────────────────────────────── + +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r5.model.Enumerations; +import org.hl7.fhir.r5.model.Observation; +import org.hl7.fhir.r5.model.Patient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.StringWriter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link FhirResourceSerializer}. + * + *

Tests are written against Jackson 2 ({@code com.fasterxml.jackson}) but are + * deliberately structured so that only the import block above and the + * {@link #createMapper(FhirContext)} factory method need to change when migrating + * to Jackson 3 ({@code tools.jackson}). All assertions operate on plain + * {@code String} and {@code JsonNode} so they are Jackson-version-agnostic. + */ +class FhirResourceSerializerTest { + + // ── Shared FHIR context (expensive to construct — reuse across tests) ──── + private static final FhirContext FHIR_CONTEXT = FhirContext.forR5(); + + private ObjectMapper myMapper; + + // ── JACKSON FACTORY METHOD ─────────────────────────────────────────────── + // Jackson 3 change: replace new ObjectMapper() + // with JsonMapper.builder().build() + // Everything else in this method stays the same. + private ObjectMapper createMapper(FhirContext theFhirContext) { + SimpleModule module = new SimpleModule(); + module.addSerializer(new FhirResourceSerializer(theFhirContext)); + ObjectMapper mapper = new ObjectMapper(); // Jackson 3: JsonMapper.builder().build() + mapper.registerModule(module); + return mapper; + } + // ── END JACKSON FACTORY METHOD ─────────────────────────────────────────── + + @BeforeEach + void setUp() { + myMapper = createMapper(FHIR_CONTEXT); + } + + // ───────────────────────────────────────────────────────────────────────── + // 1. OUTPUT FORMAT — the most critical behavioural contract: + // writeRawValue() must be used, not writeString(). + // If writeString() were used the FHIR JSON would appear as an escaped + // string value in the parent JSON, breaking all consumers. + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Output format — raw embedded JSON, not escaped string") + class OutputFormatTests { + + @Test + @DisplayName("FHIR resource is embedded as a JSON object, not a quoted string") + void serialize_embedsFhirResourceAsJsonObject_notQuotedString() throws Exception { + // Given + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + // When + String json = myMapper.writeValueAsString(wrapper); + + // Then — the value of the "resource" field must be a JSON object {…}, + // NOT a quoted string "…". If writeString() were called instead of + // writeRawValue() this assertion would fail. + JsonNode root = myMapper.readTree(json); + JsonNode resourceNode = root.get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), + "'resource' field must be a JSON object, was: " + resourceNode.getNodeType()); + } + + @Test + @DisplayName("Serialized output is valid JSON (parseable without error)") + void serialize_producesValidJson() { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + assertDoesNotThrow(() -> { + String json = myMapper.writeValueAsString(wrapper); + myMapper.readTree(json); // throws if invalid JSON + }, "Serialized output must be valid JSON"); + } + + @Test + @DisplayName("Embedded FHIR JSON contains resourceType field") + void serialize_embeddedJsonContainsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("resourceType")) + .as("Embedded FHIR JSON must contain 'resourceType'") + .isTrue(); + assertThat(resourceNode.get("resourceType").asText()) + .isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 2. PRETTY PRINT — parser is constructed with setPrettyPrint(true) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Pretty print — parser constructed with setPrettyPrint(true)") + class PrettyPrintTests { + + @Test + @DisplayName("Embedded FHIR JSON contains newlines (pretty-printed)") + void serialize_embeddedFhirJsonIsPrettyPrinted() throws Exception { + Patient patient = buildMinimalPatient(); + Wrapper wrapper = new Wrapper(patient); + + String json = myMapper.writeValueAsString(wrapper); + + // Extract the raw embedded FHIR fragment — it should contain newlines + // because the parser is created with setPrettyPrint(true). + // We check for \n in the overall output since the FHIR block is inlined. + assertThat(json) + .as("Serialized output should contain newlines from pretty-printed FHIR JSON") + .contains("\n"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 3. RESOURCE TYPE COVERAGE — serializer handles IBaseResource subtypes + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Resource type coverage") + class ResourceTypeCoverageTests { + + @Test + @DisplayName("Serializes Patient resource") + void serialize_patient() throws Exception { + Patient patient = buildMinimalPatient(); + assertSerializesWithResourceType(patient, "Patient"); + } + + @Test + @DisplayName("Serializes Patient with demographic fields") + void serialize_patientWithDemographics() throws Exception { + Patient patient = new Patient(); + patient.setId("patient-demo-1"); + patient.setGender(Enumerations.AdministrativeGender.FEMALE); + patient.addName().setFamily("Smith").addGiven("Jane"); + patient.addAddress().setCity("Boston").setState("MA").setPostalCode("02101"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.get("resourceType").asText()).isEqualTo("Patient"); + assertThat(resourceNode.has("gender")).isTrue(); + assertThat(resourceNode.get("gender").asText()).isEqualTo("female"); + assertThat(resourceNode.has("name")).isTrue(); + assertThat(resourceNode.has("address")).isTrue(); + } + + @Test + @DisplayName("Serializes Observation resource") + void serialize_observation() throws Exception { + Observation observation = new Observation(); + observation.setId("obs-1"); + observation.setStatus(Enumerations.ObservationStatus.FINAL); + assertSerializesWithResourceType(observation, "Observation"); + } + + @Test + @DisplayName("Serializes resource with ID preserved") + void serialize_preservesResourceId() throws Exception { + Patient patient = new Patient(); + patient.setId("test-patient-id-999"); + + String json = myMapper.writeValueAsString(new Wrapper(patient)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertThat(resourceNode.has("id")).isTrue(); + assertThat(resourceNode.get("id").asText()).isEqualTo("test-patient-id-999"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 4. DIRECT SERIALIZATION — serialize the resource as the root value + // (not wrapped in an outer object) + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Direct serialization — resource as root value") + class DirectSerializationTests { + + @Test + @DisplayName("Resource serialized directly to String is valid JSON") + void serialize_directlyToString_isValidJson() throws Exception { + Patient patient = buildMinimalPatient(); + + // Serialize directly (no wrapper object) + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + String json = writer.toString(); + + assertDoesNotThrow(() -> myMapper.readTree(json), + "Direct serialization output must be valid JSON"); + } + + @Test + @DisplayName("Resource serialized directly contains resourceType") + void serialize_directlyToString_containsResourceType() throws Exception { + Patient patient = buildMinimalPatient(); + + StringWriter writer = new StringWriter(); + myMapper.writeValue(writer, patient); + + JsonNode root = myMapper.readTree(writer.toString()); + assertThat(root.get("resourceType").asText()).isEqualTo("Patient"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // 5. ROUND-TRIP — serialize then re-parse the embedded FHIR JSON + // back into a FHIR resource and verify field integrity + // ───────────────────────────────────────────────────────────────────────── + @Nested + @DisplayName("Round-trip — serialize → re-parse embedded FHIR JSON") + class RoundTripTests { + + @Test + @DisplayName("Round-trip Patient preserves family name") + void roundTrip_patientFamilyName() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-1"); + original.addName().setFamily("Johnson").addGiven("Robert"); + + // Serialize + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + // Re-parse the embedded FHIR JSON back into a Patient + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getNameFirstRep().getFamily()) + .isEqualTo("Johnson"); + assertThat(reparsed.getNameFirstRep().getGivenAsSingleString()) + .isEqualTo("Robert"); + } + + @Test + @DisplayName("Round-trip Patient preserves gender") + void roundTrip_patientGender() throws Exception { + Patient original = new Patient(); + original.setId("rt-patient-2"); + original.setGender(Enumerations.AdministrativeGender.MALE); + + String json = myMapper.writeValueAsString(new Wrapper(original)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + String embeddedFhirJson = myMapper.writeValueAsString(resourceNode); + Patient reparsed = (Patient) FHIR_CONTEXT.newJsonParser() + .parseResource(embeddedFhirJson); + + assertThat(reparsed.getGender()) + .isEqualTo(Enumerations.AdministrativeGender.MALE); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + private static Patient buildMinimalPatient() { + Patient patient = new Patient(); + patient.setId("minimal-patient-1"); + return patient; + } + + private void assertSerializesWithResourceType(IBaseResource theResource, String theExpectedType) + throws Exception { + String json = myMapper.writeValueAsString(new Wrapper(theResource)); + JsonNode resourceNode = myMapper.readTree(json).get("resource"); + + assertNotNull(resourceNode, "root JSON must contain a 'resource' field"); + assertTrue(resourceNode.isObject(), "'resource' must be a JSON object"); + assertThat(resourceNode.get("resourceType").asText()).isEqualTo(theExpectedType); + } + + /** + * Simple wrapper to produce an outer JSON object with a single "resource" + * field — this lets us verify that the serializer writes a JSON object + * value rather than a JSON string value (i.e. writeRawValue vs writeString). + */ + static class Wrapper { + private final IBaseResource myResource; + + Wrapper(IBaseResource theResource) { + myResource = theResource; + } + + public IBaseResource getResource() { + return myResource; + } + } +}