From c06998ba2d77df7e93768a4b0790f64df6c41a1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Pf=C3=B6rtner?= Date: Mon, 12 Jan 2026 17:46:00 +0100 Subject: [PATCH 01/12] Add integration tests for cross-format migrations, error recovery, and field addition transformations --- aether-datafixers-functional-tests/pom.xml | 174 +++++++ .../functional/error/ErrorRecoveryIT.java | 373 ++++++++++++++ .../format/CrossFormatMigrationIT.java | 321 ++++++++++++ .../transformation/FieldAdditionE2E.java | 351 ++++++++++++++ .../transformation/FieldGroupingE2E.java | 457 ++++++++++++++++++ .../transformation/FieldRenameE2E.java | 288 +++++++++++ pom.xml | 19 + 7 files changed, 1983 insertions(+) create mode 100644 aether-datafixers-functional-tests/pom.xml create mode 100644 aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java create mode 100644 aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java create mode 100644 aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldAdditionE2E.java create mode 100644 aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldGroupingE2E.java create mode 100644 aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldRenameE2E.java diff --git a/aether-datafixers-functional-tests/pom.xml b/aether-datafixers-functional-tests/pom.xml new file mode 100644 index 0000000..55fb37a --- /dev/null +++ b/aether-datafixers-functional-tests/pom.xml @@ -0,0 +1,174 @@ + + + + 4.0.0 + + + de.splatgames.aether.datafixers + aether-datafixers + 0.5.0-SNAPSHOT + + + aether-datafixers-functional-tests + jar + + Aether Datafixers :: Functional Tests + Functional tests (E2E and integration) for Aether Datafixers + + + + true + true + + true + + 3.4.1 + + + + + + de.splatgames.aether.datafixers + aether-datafixers-api + test + + + de.splatgames.aether.datafixers + aether-datafixers-core + test + + + de.splatgames.aether.datafixers + aether-datafixers-codec + test + + + de.splatgames.aether.datafixers + aether-datafixers-testkit + test + + + de.splatgames.aether.datafixers + aether-datafixers-cli + test + + + de.splatgames.aether.datafixers + aether-datafixers-schema-tools + test + + + de.splatgames.aether.datafixers + aether-datafixers-spring-boot-starter + test + + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + + org.junit.jupiter + junit-jupiter + test + + + + + org.assertj + assertj-core + test + + + + + com.google.code.gson + gson + test + + + + + com.fasterxml.jackson.core + jackson-databind + test + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + test + + + com.fasterxml.jackson.dataformat + jackson-dataformat-toml + test + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + test + + + + + org.yaml + snakeyaml + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT.java + **/*E2E.java + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + **/*IT.java + **/*E2E.java + + + + + + diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java new file mode 100644 index 0000000..781a7de --- /dev/null +++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/error/ErrorRecoveryIT.java @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.functional.error; + +import com.google.gson.JsonElement; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.fix.DataFix; +import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.api.fix.DataFixerContext; +import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder; +import de.splatgames.aether.datafixers.testkit.TestData; +import de.splatgames.aether.datafixers.testkit.factory.QuickFix; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static de.splatgames.aether.datafixers.testkit.assertion.AetherAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for error handling and recovery scenarios. + */ +@DisplayName("Error Recovery IT") +@Tag("integration") +class ErrorRecoveryIT { + + private static final TypeReference TEST_TYPE = new TypeReference("test_entity"); + + @Nested + @DisplayName("Fix Throws Exception") + class FixThrowsException { + + @Test + @DisplayName("wraps fix exception with metadata") + void wrapsFixExceptionWithMetadata() { + final DataFix failingFix = new DataFix<>() { + @Override + public @NotNull String name() { + return "failing_fix"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(1); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + throw new RuntimeException("Intentional failure for testing"); + } + }; + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, failingFix) + .build(); + + final Dynamic input = TestData.gson().object() + .put("field", "value") + .build(); + + assertThatThrownBy(() -> fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + )) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Intentional failure"); + } + } + + @Nested + @DisplayName("Missing Data Handling") + class MissingDataHandling { + + @Test + @DisplayName("handles null field access gracefully") + void handlesNullFieldAccessGracefully() { + final Dynamic input = TestData.gson().object() + .put("existingField", "value") + .build(); + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("safe_access", 1, 2, d -> { + // Try to get a non-existent field + final Dynamic missing = d.get("nonExistentField"); + if (missing == null) { + // Handle gracefully by adding a default + return d.set("nonExistentField", d.createString("default")); + } + return d; + })) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasStringField("existingField", "value"); + assertThat(result).hasStringField("nonExistentField", "default"); + } + + @Test + @DisplayName("handles empty object migration") + void handlesEmptyObjectMigration() { + final Dynamic input = TestData.gson().object().build(); + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("empty_handler", 1, 2, d -> { + // Check if empty and add defaults + if (!d.has("id")) { + return d.set("id", d.createInt(0)) + .set("name", d.createString("unknown")); + } + return d; + })) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasIntField("id", 0); + assertThat(result).hasStringField("name", "unknown"); + } + } + + @Nested + @DisplayName("Type Conversion Errors") + class TypeConversionErrors { + + @Test + @DisplayName("handles invalid number format gracefully") + void handlesInvalidNumberFormatGracefully() { + final Dynamic input = TestData.gson().object() + .put("score", "not_a_number") + .build(); + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("safe_parse", 1, 2, d -> { + final Dynamic score = d.get("score"); + if (score != null) { + // Try to parse as int, fallback to 0 if fails + final int value = score.asInt().result().orElse(0); + return d.set("score", d.createInt(value)); + } + return d; + })) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + // Should fallback to 0 since "not_a_number" can't be parsed + assertThat(result).hasIntField("score", 0); + } + + @Test + @DisplayName("handles boolean parsing with fallback") + void handlesBooleanParsingWithFallback() { + final Dynamic input = TestData.gson().object() + .put("enabled", "yes") // String, not boolean + .build(); + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("parse_bool", 1, 2, d -> { + final Dynamic enabled = d.get("enabled"); + if (enabled != null) { + // Try boolean, then string comparison + final boolean value = enabled.asBoolean().result() + .orElseGet(() -> { + final String str = enabled.asString().result().orElse(""); + return "yes".equalsIgnoreCase(str) || "true".equalsIgnoreCase(str); + }); + return d.set("enabled", d.createBoolean(value)); + } + return d; + })) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasBooleanField("enabled", true); + } + } + + @Nested + @DisplayName("Chain Recovery") + class ChainRecovery { + + @Test + @DisplayName("continues chain after successful fix handles bad data") + void continuesChainAfterSuccessfulRecovery() { + final Dynamic input = TestData.gson().object() + .put("name", "test") + .put("badValue", "invalid") + .build(); + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(TEST_TYPE, QuickFix.simple("fix_bad_value", 1, 2, d -> { + // Fix the bad value by replacing it + return d.set("badValue", d.createInt(0)); + })) + .addFix(TEST_TYPE, QuickFix.simple("use_fixed_value", 2, 3, d -> { + // This fix depends on the fixed value + final int value = d.get("badValue").asInt().result().orElse(-1); + return d.set("processedValue", d.createInt(value * 2)); + })) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(3) + ); + + assertThat(result).hasIntField("badValue", 0); + assertThat(result).hasIntField("processedValue", 0); + } + } + + @Nested + @DisplayName("Validation Errors") + class ValidationErrors { + + @Test + @DisplayName("fix can add validation warnings via context") + void fixCanAddValidationWarningsViaContext() { + final java.util.List warnings = new java.util.ArrayList<>(); + + final DataFixerContext trackingContext = new DataFixerContext() { + @Override + public void info(@NotNull final String message, final Object... args) { + // Ignore info + } + + @Override + public void warn(@NotNull final String message, final Object... args) { + warnings.add(String.format(message, args)); + } + }; + + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .withDefaultContext(trackingContext) + .addFix(TEST_TYPE, new DataFix() { + @Override + public @NotNull String name() { + return "warning_fix"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(1); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + if (!input.has("requiredField")) { + context.warn("Missing required field 'requiredField', using default"); + } + return input.set("requiredField", + input.get("requiredField") != null + ? input.get("requiredField") + : input.createString("default")); + } + }) + .build(); + + final Dynamic input = TestData.gson().object() + .put("otherField", "value") + .build(); + + fixer.update(TEST_TYPE, input, new DataVersion(1), new DataVersion(2)); + + org.assertj.core.api.Assertions.assertThat(warnings).hasSize(1); + org.assertj.core.api.Assertions.assertThat(warnings.get(0)) + .contains("Missing required field"); + } + } + + @Nested + @DisplayName("Idempotent Fixes") + class IdempotentFixes { + + @Test + @DisplayName("fix is idempotent when applied multiple times") + void fixIsIdempotentWhenAppliedMultipleTimes() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("idempotent_fix", 1, 2, d -> { + // Only add field if not present + if (d.has("addedField")) { + return d; + } + return d.set("addedField", d.createInt(1)); + })) + .build(); + + final Dynamic input = TestData.gson().object() + .put("original", "value") + .build(); + + // First application + final Dynamic first = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + // "Second application" (simulated by creating new fixer that allows this) + final DataFixer secondFixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(TEST_TYPE, QuickFix.simple("idempotent_fix_2", 2, 3, d -> { + if (d.has("addedField")) { + return d; + } + return d.set("addedField", d.createInt(1)); + })) + .build(); + + final Dynamic second = secondFixer.update( + TEST_TYPE, first, + new DataVersion(2), new DataVersion(3) + ); + + // Field should still have value 1, not incremented or changed + assertThat(second).hasIntField("addedField", 1); + } + } +} diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java new file mode 100644 index 0000000..3c6133f --- /dev/null +++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/format/CrossFormatMigrationIT.java @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.functional.format; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.gson.JsonElement; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; +import de.splatgames.aether.datafixers.codec.json.jackson.JacksonJsonOps; +import de.splatgames.aether.datafixers.codec.yaml.jackson.JacksonYamlOps; +import de.splatgames.aether.datafixers.codec.yaml.snakeyaml.SnakeYamlOps; +import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder; +import de.splatgames.aether.datafixers.testkit.TestData; +import de.splatgames.aether.datafixers.testkit.factory.QuickFix; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for migrations across different data formats. + */ +@DisplayName("Cross-Format Migration IT") +@Tag("integration") +class CrossFormatMigrationIT { + + private static final TypeReference PLAYER = new TypeReference("player"); + + @Nested + @DisplayName("Gson Format") + class GsonFormat { + + @Test + @DisplayName("completes full migration chain with Gson") + void completesFullMigrationChainWithGson() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(PLAYER, QuickFix.renameField( + GsonOps.INSTANCE, "rename_name", 1, 2, "playerName", "name")) + .addFix(PLAYER, QuickFix.renameField( + GsonOps.INSTANCE, "rename_xp", 2, 3, "xp", "experience")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("playerName", "GsonPlayer") + .put("xp", 1000) + .put("level", 10) + .build(); + + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(1), new DataVersion(3) + ); + + assertThat(result.get("name").asString().result()).contains("GsonPlayer"); + assertThat(result.get("experience").asInt().result()).contains(1000); + assertThat(result.get("level").asInt().result()).contains(10); + } + + @Test + @DisplayName("handles partial migration with Gson") + void handlesPartialMigrationWithGson() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(PLAYER, QuickFix.renameField( + GsonOps.INSTANCE, "rename_v1_v2", 1, 2, "oldField", "midField")) + .addFix(PLAYER, QuickFix.renameField( + GsonOps.INSTANCE, "rename_v2_v3", 2, 3, "midField", "newField")) + .build(); + + // Start from v2, should only apply v2->v3 + final Dynamic input = TestData.gson().object() + .put("midField", "value") + .build(); + + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(2), new DataVersion(3) + ); + + assertThat(result.get("newField").asString().result()).contains("value"); + assertThat(result.get("midField")).isNull(); + } + } + + @Nested + @DisplayName("Jackson JSON Format") + class JacksonJsonFormat { + + @Test + @DisplayName("completes full migration chain with Jackson JSON") + void completesFullMigrationChainWithJacksonJson() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(PLAYER, QuickFix.renameField( + JacksonJsonOps.INSTANCE, "rename_name", 1, 2, "playerName", "name")) + .addFix(PLAYER, QuickFix.renameField( + JacksonJsonOps.INSTANCE, "rename_xp", 2, 3, "xp", "experience")) + .build(); + + final Dynamic input = TestData.jacksonJson().object() + .put("playerName", "JacksonPlayer") + .put("xp", 2000) + .put("score", 500) + .build(); + + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(1), new DataVersion(3) + ); + + assertThat(result.get("name").asString().result()).contains("JacksonPlayer"); + assertThat(result.get("experience").asInt().result()).contains(2000); + assertThat(result.get("score").asInt().result()).contains(500); + } + } + + @Nested + @DisplayName("Jackson YAML Format") + class JacksonYamlFormat { + + @Test + @DisplayName("completes full migration chain with Jackson YAML") + void completesFullMigrationChainWithJacksonYaml() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(PLAYER, QuickFix.renameField( + JacksonYamlOps.INSTANCE, "rename_name", 1, 2, "playerName", "name")) + .addFix(PLAYER, QuickFix.addIntField( + JacksonYamlOps.INSTANCE, "add_level", 2, 3, "level", 1)) + .build(); + + final Dynamic input = TestData.jacksonYaml().object() + .put("playerName", "YamlPlayer") + .put("score", 3000) + .build(); + + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(1), new DataVersion(3) + ); + + assertThat(result.get("name").asString().result()).contains("YamlPlayer"); + assertThat(result.get("score").asInt().result()).contains(3000); + assertThat(result.get("level").asInt().result()).contains(1); + } + } + + @Nested + @DisplayName("SnakeYAML Format") + class SnakeYamlFormat { + + @Test + @DisplayName("completes migration with SnakeYAML") + void completesMigrationWithSnakeYaml() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(PLAYER, QuickFix.renameField( + SnakeYamlOps.INSTANCE, "rename_name", 1, 2, "playerName", "name")) + .build(); + + final Dynamic input = TestData.snakeYaml().object() + .put("playerName", "SnakePlayer") + .put("score", 4000) + .build(); + + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result.get("name").asString().result()).contains("SnakePlayer"); + assertThat(result.get("score").asInt().result()).contains(4000); + } + } + + @Nested + @DisplayName("Format Consistency") + class FormatConsistency { + + @Test + @DisplayName("same migration produces consistent results across Gson and Jackson") + void sameMigrationProducesConsistentResultsAcrossFormats() { + // Create equivalent fixers for each format + final DataFixer gsonFixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(PLAYER, QuickFix.renameField( + GsonOps.INSTANCE, "rename", 1, 2, "oldField", "newField")) + .build(); + + final DataFixer jacksonFixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(PLAYER, QuickFix.renameField( + JacksonJsonOps.INSTANCE, "rename", 1, 2, "oldField", "newField")) + .build(); + + // Apply to equivalent inputs + final Dynamic gsonInput = TestData.gson().object() + .put("oldField", "testValue") + .put("untouched", 42) + .build(); + + final Dynamic jacksonInput = TestData.jacksonJson().object() + .put("oldField", "testValue") + .put("untouched", 42) + .build(); + + final Dynamic gsonResult = gsonFixer.update( + PLAYER, gsonInput, + new DataVersion(1), new DataVersion(2) + ); + + final Dynamic jacksonResult = jacksonFixer.update( + PLAYER, jacksonInput, + new DataVersion(1), new DataVersion(2) + ); + + // Both should have the same field names and values + assertThat(gsonResult.get("newField").asString().result()) + .isEqualTo(jacksonResult.get("newField").asString().result()) + .contains("testValue"); + + assertThat(gsonResult.get("untouched").asInt().result()) + .isEqualTo(jacksonResult.get("untouched").asInt().result()) + .contains(42); + + // Both should not have old field + assertThat(gsonResult.get("oldField")).isNull(); + assertThat(jacksonResult.get("oldField")).isNull(); + } + } + + @Nested + @DisplayName("Complex Nested Structures") + class ComplexNestedStructures { + + @Test + @DisplayName("migrates complex nested structure with Gson") + void migratesComplexNestedStructureWithGson() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(PLAYER, QuickFix.simple("rename_in_nested", 1, 2, d -> { + final Dynamic settings = d.get("settings"); + if (settings != null) { + final Dynamic oldValue = settings.get("oldSetting"); + if (oldValue != null) { + final Dynamic newSettings = settings.remove("oldSetting") + .set("newSetting", oldValue); + return d.set("settings", newSettings); + } + } + return d; + })) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "Player") + .putObject("settings", s -> s + .put("oldSetting", "value") + .put("otherSetting", 100)) + .build(); + + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result.get("name").asString().result()).contains("Player"); + assertThat(result.get("settings").get("newSetting").asString().result()).contains("value"); + assertThat(result.get("settings").get("otherSetting").asInt().result()).contains(100); + assertThat(result.get("settings").get("oldSetting")).isNull(); + } + } + + @Nested + @DisplayName("No-Op Migration") + class NoOpMigration { + + @Test + @DisplayName("no-op migration preserves data unchanged") + void noOpMigrationPreservesDataUnchanged() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(5)) + .addFix(PLAYER, QuickFix.renameField( + GsonOps.INSTANCE, "rename", 1, 2, "a", "b")) + .build(); + + // Data is already at version 3, fixer only has v1->v2 fix + final Dynamic input = TestData.gson().object() + .put("field1", "value1") + .put("field2", 42) + .build(); + + // Migration from v3 to v5 should not modify anything (no fixes in that range) + final Dynamic result = fixer.update( + PLAYER, input, + new DataVersion(3), new DataVersion(5) + ); + + // Data should be unchanged + assertThat(result.get("field1").asString().result()).contains("value1"); + assertThat(result.get("field2").asInt().result()).contains(42); + } + } +} diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldAdditionE2E.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldAdditionE2E.java new file mode 100644 index 0000000..da9e423 --- /dev/null +++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldAdditionE2E.java @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.functional.transformation; + +import com.google.gson.JsonElement; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; +import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder; +import de.splatgames.aether.datafixers.testkit.TestData; +import de.splatgames.aether.datafixers.testkit.factory.QuickFix; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static de.splatgames.aether.datafixers.testkit.assertion.AetherAssertions.assertThat; + +/** + * E2E tests for field addition transformations. + */ +@DisplayName("Field Addition E2E") +@Tag("e2e") +class FieldAdditionE2E { + + private static final TypeReference TEST_TYPE = new TypeReference("test_entity"); + + @Nested + @DisplayName("Add String Fields") + class AddStringFields { + + @Test + @DisplayName("adds string field with default value") + void addsStringFieldWithDefaultValue() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.addStringField( + GsonOps.INSTANCE, "add_string", 1, 2, "newString", "defaultValue")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("existingField", "existingValue") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasStringField("newString", "defaultValue"); + assertThat(result).hasStringField("existingField", "existingValue"); + } + + @Test + @DisplayName("does not overwrite existing string field") + void doesNotOverwriteExistingStringField() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.addStringField( + GsonOps.INSTANCE, "add_string", 1, 2, "targetField", "defaultValue")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("targetField", "customValue") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + // Should keep original value, not overwrite + assertThat(result).hasStringField("targetField", "customValue"); + } + } + + @Nested + @DisplayName("Add Integer Fields") + class AddIntegerFields { + + @Test + @DisplayName("adds integer field with default value") + void addsIntegerFieldWithDefaultValue() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.addIntField( + GsonOps.INSTANCE, "add_int", 1, 2, "score", 0)) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "TestEntity") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasIntField("score", 0); + assertThat(result).hasStringField("name", "TestEntity"); + } + + @Test + @DisplayName("does not overwrite existing integer field") + void doesNotOverwriteExistingIntegerField() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.addIntField( + GsonOps.INSTANCE, "add_int", 1, 2, "level", 1)) + .build(); + + final Dynamic input = TestData.gson().object() + .put("level", 50) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasIntField("level", 50); + } + } + + @Nested + @DisplayName("Add Boolean Fields") + class AddBooleanFields { + + @Test + @DisplayName("adds boolean field with default value") + void addsBooleanFieldWithDefaultValue() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.addBooleanField( + GsonOps.INSTANCE, "add_bool", 1, 2, "active", true)) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "TestEntity") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasBooleanField("active", true); + assertThat(result).hasStringField("name", "TestEntity"); + } + + @Test + @DisplayName("adds false as default boolean") + void addsFalseAsDefaultBoolean() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.addBooleanField( + GsonOps.INSTANCE, "add_bool", 1, 2, "disabled", false)) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "TestEntity") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasBooleanField("disabled", false); + } + } + + @Nested + @DisplayName("Add Double Fields") + class AddDoubleFields { + + @Test + @DisplayName("adds double field with default value") + void addsDoubleFieldWithDefaultValue() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("add_double", 1, 2, d -> { + if (d.get("coordinate") == null) { + return d.set("coordinate", d.createDouble(0.0)); + } + return d; + })) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "TestEntity") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasDoubleField("coordinate", 0.0, 0.001); + assertThat(result).hasStringField("name", "TestEntity"); + } + } + + @Nested + @DisplayName("Add Multiple Fields in Chain") + class AddMultipleFieldsInChain { + + @Test + @DisplayName("adds multiple fields across versions") + void addsMultipleFieldsAcrossVersions() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(4)) + .addFix(TEST_TYPE, QuickFix.addStringField( + GsonOps.INSTANCE, "add_type", 1, 2, "type", "unknown")) + .addFix(TEST_TYPE, QuickFix.addIntField( + GsonOps.INSTANCE, "add_score", 2, 3, "score", 0)) + .addFix(TEST_TYPE, QuickFix.addBooleanField( + GsonOps.INSTANCE, "add_active", 3, 4, "active", true)) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "ChainEntity") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(4) + ); + + assertThat(result).hasStringField("name", "ChainEntity"); + assertThat(result).hasStringField("type", "unknown"); + assertThat(result).hasIntField("score", 0); + assertThat(result).hasBooleanField("active", true); + } + } + + @Nested + @DisplayName("Add Computed Fields") + class AddComputedFields { + + @Test + @DisplayName("adds computed field based on existing data") + void addsComputedFieldBasedOnExistingData() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("add_full_name", 1, 2, d -> { + final String first = d.get("firstName").asString().result().orElse(""); + final String last = d.get("lastName").asString().result().orElse(""); + return d.set("fullName", d.createString(first + " " + last)); + })) + .build(); + + final Dynamic input = TestData.gson().object() + .put("firstName", "John") + .put("lastName", "Doe") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasStringField("fullName", "John Doe"); + assertThat(result).hasStringField("firstName", "John"); + assertThat(result).hasStringField("lastName", "Doe"); + } + + @Test + @DisplayName("adds version field") + void addsVersionField() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("add_version", 1, 2, d -> { + if (d.get("version") == null) { + return d.set("version", d.createInt(2)); + } + return d; + })) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "TestEntity") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasIntField("version", 2); + } + } + + @Nested + @DisplayName("Conditional Field Addition") + class ConditionalFieldAddition { + + @Test + @DisplayName("adds field only if condition is met") + void addsFieldOnlyIfConditionIsMet() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.simple("conditional_add", 1, 2, d -> { + // Only add premium field if level > 10 + final int level = d.get("level").asInt().result().orElse(0); + if (level > 10 && d.get("premium") == null) { + return d.set("premium", d.createBoolean(true)); + } + return d; + })) + .build(); + + final Dynamic highLevelInput = TestData.gson().object() + .put("name", "HighLevel") + .put("level", 15) + .build(); + + final Dynamic lowLevelInput = TestData.gson().object() + .put("name", "LowLevel") + .put("level", 5) + .build(); + + final Dynamic highResult = fixer.update( + TEST_TYPE, highLevelInput, + new DataVersion(1), new DataVersion(2) + ); + + final Dynamic lowResult = fixer.update( + TEST_TYPE, lowLevelInput, + new DataVersion(1), new DataVersion(2) + ); + + // High level gets premium + assertThat(highResult).hasBooleanField("premium", true); + + // Low level does not get premium + assertThat(lowResult).doesNotHaveField("premium"); + } + } +} diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldGroupingE2E.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldGroupingE2E.java new file mode 100644 index 0000000..78d2af9 --- /dev/null +++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldGroupingE2E.java @@ -0,0 +1,457 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.functional.transformation; + +import com.google.gson.JsonElement; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.fix.DataFix; +import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.api.fix.DataFixerContext; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; +import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder; +import de.splatgames.aether.datafixers.testkit.TestData; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static de.splatgames.aether.datafixers.testkit.assertion.AetherAssertions.assertThat; + +/** + * E2E tests for field grouping and ungrouping transformations. + */ +@DisplayName("Field Grouping E2E") +@Tag("e2e") +class FieldGroupingE2E { + + private static final TypeReference TEST_TYPE = new TypeReference("test_entity"); + + @Nested + @DisplayName("Group Flat Fields into Nested Object") + class GroupFlatFields { + + @Test + @DisplayName("groups x, y, z into position object") + void groupsCoordinatesIntoPosition() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, new GroupPositionFix()) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "Entity1") + .put("x", 100.0) + .put("y", 64.0) + .put("z", -200.0) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + // Original fields should be removed + assertThat(result).doesNotHaveField("x"); + assertThat(result).doesNotHaveField("y"); + assertThat(result).doesNotHaveField("z"); + + // Position should be a nested object + assertThat(result).hasField("position"); + assertThat(result.get("position")).hasDoubleField("x", 100.0, 0.01); + assertThat(result.get("position")).hasDoubleField("y", 64.0, 0.01); + assertThat(result.get("position")).hasDoubleField("z", -200.0, 0.01); + + // Other fields preserved + assertThat(result).hasStringField("name", "Entity1"); + } + + @Test + @DisplayName("groups address fields into address object") + void groupsAddressFieldsIntoAddressObject() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, new GroupAddressFix()) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "John Doe") + .put("street", "123 Main St") + .put("city", "Anytown") + .put("zipCode", "12345") + .put("country", "USA") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasStringField("name", "John Doe"); + assertThat(result).hasField("address"); + assertThat(result.get("address")).hasStringField("street", "123 Main St"); + assertThat(result.get("address")).hasStringField("city", "Anytown"); + assertThat(result.get("address")).hasStringField("zipCode", "12345"); + assertThat(result.get("address")).hasStringField("country", "USA"); + } + } + + @Nested + @DisplayName("Flatten Nested Object into Flat Fields") + class FlattenNestedObject { + + @Test + @DisplayName("flattens position object into x, y, z fields") + void flattensPositionObjectIntoCoordinates() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, new FlattenPositionFix()) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "Entity2") + .putObject("position", pos -> pos + .put("x", 50.0) + .put("y", 70.0) + .put("z", 150.0)) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).doesNotHaveField("position"); + assertThat(result).hasDoubleField("x", 50.0, 0.01); + assertThat(result).hasDoubleField("y", 70.0, 0.01); + assertThat(result).hasDoubleField("z", 150.0, 0.01); + assertThat(result).hasStringField("name", "Entity2"); + } + } + + @Nested + @DisplayName("Partial Grouping") + class PartialGrouping { + + @Test + @DisplayName("handles missing fields during grouping") + void handlesMissingFieldsDuringGrouping() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, new GroupPositionFix()) + .build(); + + // Only x and y are present, z is missing + final Dynamic input = TestData.gson().object() + .put("name", "PartialEntity") + .put("x", 10.0) + .put("y", 20.0) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + // Should remain flat since z is missing + assertThat(result).hasDoubleField("x", 10.0, 0.01); + assertThat(result).hasDoubleField("y", 20.0, 0.01); + assertThat(result).doesNotHaveField("position"); + } + } + + @Nested + @DisplayName("Nested to Deeper Nesting") + class DeepNesting { + + @Test + @DisplayName("moves nested object into deeper structure") + void movesNestedObjectIntoDeeperStructure() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, new NestSettingsFix()) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "DeepEntity") + .putObject("settings", s -> s + .put("volume", 80) + .put("brightness", 50)) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).doesNotHaveField("settings"); + assertThat(result).hasField("config"); + assertThat(result.get("config")).hasField("display"); + assertThat(result.get("config").get("display")).hasIntField("volume", 80); + assertThat(result.get("config").get("display")).hasIntField("brightness", 50); + } + } + + @Nested + @DisplayName("Combined Group and Ungroup") + class CombinedGroupAndUngroup { + + @Test + @DisplayName("groups and ungroups fields across versions") + void groupsAndUngroupsFieldsAcrossVersions() { + // V1 -> V2: Group into position + // V2 -> V3: Ungroup back to flat with renamed fields + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(TEST_TYPE, new GroupPositionFix()) + .addFix(TEST_TYPE, new UngroupPositionWithRenameFix()) + .build(); + + final Dynamic input = TestData.gson().object() + .put("name", "TransformEntity") + .put("x", 1.0) + .put("y", 2.0) + .put("z", 3.0) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(3) + ); + + // After round-trip: now flat with renamed fields + assertThat(result).doesNotHaveField("x"); + assertThat(result).doesNotHaveField("y"); + assertThat(result).doesNotHaveField("z"); + assertThat(result).doesNotHaveField("position"); + assertThat(result).hasDoubleField("posX", 1.0, 0.01); + assertThat(result).hasDoubleField("posY", 2.0, 0.01); + assertThat(result).hasDoubleField("posZ", 3.0, 0.01); + } + } + + // ==================== Helper Fix Classes ==================== + + /** + * Groups x, y, z fields into a position object. + */ + static class GroupPositionFix implements DataFix { + @Override + public @NotNull String name() { + return "group_position"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(1); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + final Dynamic x = input.get("x"); + final Dynamic y = input.get("y"); + final Dynamic z = input.get("z"); + + if (x != null && y != null && z != null) { + // Create position object using emptyMap and set + Dynamic position = new Dynamic<>(GsonOps.INSTANCE, GsonOps.INSTANCE.emptyMap()); + position = position.set("x", x); + position = position.set("y", y); + position = position.set("z", z); + + return input.remove("x").remove("y").remove("z") + .set("position", position); + } + return input; + } + } + + /** + * Flattens position object back to x, y, z fields. + */ + static class FlattenPositionFix implements DataFix { + @Override + public @NotNull String name() { + return "flatten_position"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(1); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + final Dynamic position = input.get("position"); + if (position != null) { + final Dynamic x = position.get("x"); + final Dynamic y = position.get("y"); + final Dynamic z = position.get("z"); + + Dynamic result = input.remove("position"); + if (x != null) result = result.set("x", x); + if (y != null) result = result.set("y", y); + if (z != null) result = result.set("z", z); + return result; + } + return input; + } + } + + /** + * Groups address fields into an address object. + */ + static class GroupAddressFix implements DataFix { + @Override + public @NotNull String name() { + return "group_address"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(1); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + final Dynamic street = input.get("street"); + final Dynamic city = input.get("city"); + final Dynamic zipCode = input.get("zipCode"); + final Dynamic country = input.get("country"); + + if (street != null && city != null) { + Dynamic address = new Dynamic<>(GsonOps.INSTANCE, GsonOps.INSTANCE.emptyMap()); + address = address.set("street", street); + address = address.set("city", city); + if (zipCode != null) { + address = address.set("zipCode", zipCode); + } + if (country != null) { + address = address.set("country", country); + } + return input.remove("street").remove("city").remove("zipCode").remove("country") + .set("address", address); + } + return input; + } + } + + /** + * Nests settings into config.display. + */ + static class NestSettingsFix implements DataFix { + @Override + public @NotNull String name() { + return "nest_settings"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(1); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + final Dynamic settings = input.get("settings"); + if (settings != null) { + Dynamic config = new Dynamic<>(GsonOps.INSTANCE, GsonOps.INSTANCE.emptyMap()); + config = config.set("display", settings); + return input.remove("settings").set("config", config); + } + return input; + } + } + + /** + * Ungroups position to posX, posY, posZ fields. + */ + static class UngroupPositionWithRenameFix implements DataFix { + @Override + public @NotNull String name() { + return "ungroup_position"; + } + + @Override + public @NotNull DataVersion fromVersion() { + return new DataVersion(2); + } + + @Override + public @NotNull DataVersion toVersion() { + return new DataVersion(3); + } + + @Override + public @NotNull Dynamic apply( + @NotNull final TypeReference type, + @NotNull final Dynamic input, + @NotNull final DataFixerContext context + ) { + final Dynamic position = input.get("position"); + if (position != null) { + final Dynamic x = position.get("x"); + final Dynamic y = position.get("y"); + final Dynamic z = position.get("z"); + + Dynamic result = input.remove("position"); + if (x != null) result = result.set("posX", x); + if (y != null) result = result.set("posY", y); + if (z != null) result = result.set("posZ", z); + return result; + } + return input; + } + } +} diff --git a/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldRenameE2E.java b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldRenameE2E.java new file mode 100644 index 0000000..ca8b144 --- /dev/null +++ b/aether-datafixers-functional-tests/src/test/java/de/splatgames/aether/datafixers/functional/transformation/FieldRenameE2E.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2025 Splatgames.de Software and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package de.splatgames.aether.datafixers.functional.transformation; + +import com.google.gson.JsonElement; +import de.splatgames.aether.datafixers.api.DataVersion; +import de.splatgames.aether.datafixers.api.TypeReference; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.fix.DataFixer; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; +import de.splatgames.aether.datafixers.core.fix.DataFixerBuilder; +import de.splatgames.aether.datafixers.testkit.TestData; +import de.splatgames.aether.datafixers.testkit.factory.QuickFix; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static de.splatgames.aether.datafixers.testkit.assertion.AetherAssertions.assertThat; + +/** + * E2E tests for field rename transformations. + */ +@DisplayName("Field Rename E2E") +@Tag("e2e") +class FieldRenameE2E { + + private static final TypeReference TEST_TYPE = new TypeReference("test_entity"); + + @Nested + @DisplayName("Basic Field Rename") + class BasicFieldRename { + + @Test + @DisplayName("renames string field correctly") + void renamesStringFieldCorrectly() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_string", 1, 2, "oldName", "newName")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("oldName", "TestValue") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasStringField("newName", "TestValue"); + assertThat(result).doesNotHaveField("oldName"); + } + + @Test + @DisplayName("renames integer field correctly") + void renamesIntegerFieldCorrectly() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_int", 1, 2, "oldCount", "newCount")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("oldCount", 42) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasIntField("newCount", 42); + assertThat(result).doesNotHaveField("oldCount"); + } + + @Test + @DisplayName("renames boolean field correctly") + void renamesBooleanFieldCorrectly() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_bool", 1, 2, "oldFlag", "newFlag")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("oldFlag", true) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasBooleanField("newFlag", true); + assertThat(result).doesNotHaveField("oldFlag"); + } + } + + @Nested + @DisplayName("Field Rename with Other Fields") + class RenameWithOtherFields { + + @Test + @DisplayName("preserves other fields when renaming") + void preservesOtherFieldsWhenRenaming() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "selective_rename", 1, 2, "targetField", "renamedField")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("targetField", "rename_me") + .put("preservedField1", "keep_me") + .put("preservedField2", 100) + .put("preservedField3", true) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasStringField("renamedField", "rename_me"); + assertThat(result).hasStringField("preservedField1", "keep_me"); + assertThat(result).hasIntField("preservedField2", 100); + assertThat(result).hasBooleanField("preservedField3", true); + assertThat(result).doesNotHaveField("targetField"); + } + } + + @Nested + @DisplayName("Missing Field Handling") + class MissingFieldHandling { + + @Test + @DisplayName("gracefully handles missing field to rename") + void gracefullyHandlesMissingFieldToRename() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_missing", 1, 2, "nonExistent", "newField")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("differentField", "value") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + // Original field should still exist + assertThat(result).hasStringField("differentField", "value"); + // New field should NOT be created (no source value) + assertThat(result).doesNotHaveField("newField"); + } + } + + @Nested + @DisplayName("Multiple Renames") + class MultipleRenames { + + @Test + @DisplayName("applies multiple renames in sequence") + void appliesMultipleRenamesInSequence() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(4)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_a", 1, 2, "field_a", "renamed_a")) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_b", 2, 3, "field_b", "renamed_b")) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_c", 3, 4, "field_c", "renamed_c")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("field_a", "value_a") + .put("field_b", "value_b") + .put("field_c", "value_c") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(4) + ); + + assertThat(result).hasStringField("renamed_a", "value_a"); + assertThat(result).hasStringField("renamed_b", "value_b"); + assertThat(result).hasStringField("renamed_c", "value_c"); + assertThat(result).doesNotHaveField("field_a"); + assertThat(result).doesNotHaveField("field_b"); + assertThat(result).doesNotHaveField("field_c"); + } + + @Test + @DisplayName("applies chain rename (a->b->c)") + void appliesChainRename() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(3)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_1", 1, 2, "originalName", "intermediateName")) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_2", 2, 3, "intermediateName", "finalName")) + .build(); + + final Dynamic input = TestData.gson().object() + .put("originalName", "chain_value") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(3) + ); + + assertThat(result).hasStringField("finalName", "chain_value"); + assertThat(result).doesNotHaveField("originalName"); + assertThat(result).doesNotHaveField("intermediateName"); + } + } + + @Nested + @DisplayName("Rename Complex Values") + class RenameComplexValues { + + @Test + @DisplayName("renames field with nested object value") + void renamesFieldWithNestedObjectValue() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_nested", 1, 2, "oldNestedField", "newNestedField")) + .build(); + + final Dynamic input = TestData.gson().object() + .putObject("oldNestedField", nested -> nested + .put("innerKey", "innerValue") + .put("innerNumber", 42)) + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasField("newNestedField"); + assertThat(result.get("newNestedField")).hasStringField("innerKey", "innerValue"); + assertThat(result.get("newNestedField")).hasIntField("innerNumber", 42); + assertThat(result).doesNotHaveField("oldNestedField"); + } + + @Test + @DisplayName("renames field with array value") + void renamesFieldWithArrayValue() { + final DataFixer fixer = new DataFixerBuilder(new DataVersion(2)) + .addFix(TEST_TYPE, QuickFix.renameField( + GsonOps.INSTANCE, "rename_list", 1, 2, "oldListField", "newListField")) + .build(); + + final Dynamic input = TestData.gson().object() + .putStrings("oldListField", "item1", "item2", "item3") + .build(); + + final Dynamic result = fixer.update( + TEST_TYPE, input, + new DataVersion(1), new DataVersion(2) + ); + + assertThat(result).hasField("newListField"); + assertThat(result).doesNotHaveField("oldListField"); + } + } +} diff --git a/pom.xml b/pom.xml index f769ba1..d4b6525 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ aether-datafixers-spring-boot-starter aether-datafixers-examples aether-datafixers-bom + aether-datafixers-functional-tests @@ -44,6 +45,7 @@ 3.20.0 3.2.6 3.1.2 + 3.1.2 3.5.1 @@ -279,6 +281,23 @@ false + + org.apache.maven.plugins + maven-failsafe-plugin + ${plugin.failsafe.version} + + false + false + + + + + integration-test + verify + + + + From c1b92eb0e4d15ce4ec6c673f84cb353813ed1c5f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 19:40:33 +0100 Subject: [PATCH 02/12] Add integration test workflow and Maven failsafe configuration - Introduced `integration-tests` workflow for E2E and integration testing on Java 17 and 21. - Configured Maven Failsafe Plugin with `it` profile to enable integration tests. - Set up `skipITs` property to skip ITs by default, with an option to enable them via `it` profile. --- .github/workflows/ci.yml | 43 +++++++++++++++++++++- aether-datafixers-functional-tests/pom.xml | 2 + pom.xml | 11 ++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b421524..cb86c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,6 @@ jobs: name: test-results-java-${{ matrix.java }} path: | **/target/surefire-reports/ - **/target/failsafe-reports/ retention-days: 7 - name: Upload coverage report @@ -63,6 +62,48 @@ jobs: report_paths: '**/target/*-reports/TEST-*.xml' check_name: Test Report (Java ${{ matrix.java }}) + integration-tests: + name: Integration & E2E Tests (Java ${{ matrix.java }}) + runs-on: ubuntu-latest + needs: build + if: github.event_name == 'pull_request' + strategy: + fail-fast: false + matrix: + java: [ '17', '21' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + cache: 'maven' + + - name: Run integration tests + run: mvn -B clean verify -Pit -Ddependency-check.skip=true + + - name: Upload IT test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: it-test-results-java-${{ matrix.java }} + path: | + **/target/failsafe-reports/ + retention-days: 7 + + - name: Publish IT Test Report + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: '**/target/failsafe-reports/TEST-*.xml' + check_name: IT Test Report (Java ${{ matrix.java }}) + quality: name: Code Quality Analysis runs-on: ubuntu-latest diff --git a/aether-datafixers-functional-tests/pom.xml b/aether-datafixers-functional-tests/pom.xml index 55fb37a..0accc6a 100644 --- a/aether-datafixers-functional-tests/pom.xml +++ b/aether-datafixers-functional-tests/pom.xml @@ -159,10 +159,12 @@ + org.apache.maven.plugins maven-failsafe-plugin + ${skipITs} **/*IT.java **/*E2E.java diff --git a/pom.xml b/pom.xml index d4b6525..c3a62a6 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,9 @@ 17 true + + true + 26.0.2 2.13.2 @@ -371,6 +374,14 @@ + + + it + + false + + + qa From ce8622db778d9f70ffc0dc1f6aad18a0227f5622 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 19:49:51 +0100 Subject: [PATCH 03/12] Escape workflow name with quotes for consistent formatting in `ci.yml`. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb86c34..659e4a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,9 +63,8 @@ jobs: check_name: Test Report (Java ${{ matrix.java }}) integration-tests: - name: Integration & E2E Tests (Java ${{ matrix.java }}) + name: "Integration & E2E Tests (Java ${{ matrix.java }})" runs-on: ubuntu-latest - needs: build if: github.event_name == 'pull_request' strategy: fail-fast: false From f40b1edc714df60a6bdcc5d30940dbb0c040c043 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 19:51:31 +0100 Subject: [PATCH 04/12] Remove redundant condition from `integration-tests` workflow trigger. --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 659e4a4..8f0bb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,6 @@ jobs: integration-tests: name: "Integration & E2E Tests (Java ${{ matrix.java }})" runs-on: ubuntu-latest - if: github.event_name == 'pull_request' strategy: fail-fast: false matrix: From 62ba61b9dd5e4a9ccbaea0ea1b0b19206bb2b5ab Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 19:53:04 +0100 Subject: [PATCH 05/12] Restrict `integration-tests` workflow to trigger only on pull request events. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f0bb43..659e4a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: integration-tests: name: "Integration & E2E Tests (Java ${{ matrix.java }})" runs-on: ubuntu-latest + if: github.event_name == 'pull_request' strategy: fail-fast: false matrix: From cc805b4e69898efdab9fd6a5f72aa7b4bdd15c97 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 19:54:47 +0100 Subject: [PATCH 06/12] Reorder `integration-tests` workflow steps for clarity. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 659e4a4..0282b17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,13 +63,13 @@ jobs: check_name: Test Report (Java ${{ matrix.java }}) integration-tests: - name: "Integration & E2E Tests (Java ${{ matrix.java }})" runs-on: ubuntu-latest if: github.event_name == 'pull_request' strategy: fail-fast: false matrix: java: [ '17', '21' ] + name: "Integration & E2E Tests (Java ${{ matrix.java }})" steps: - name: Checkout repository From 84b5139ebbb4198ea8a18077c039e6b125067769 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 20:04:40 +0100 Subject: [PATCH 07/12] Split CI workflow into separate push and pull_request workflows - Extracted `pull_request` logic to a new `ci-pr.yml` file for better separation of concerns. - Updated `ci.yml` to handle only `push` events. - Preserved integration tests and reporting logic, now scoped specifically to pull request events. --- .github/workflows/ci-pr.yml | 76 +++++++++++++++++++++++ .github/workflows/{ci.yml => ci-push.yml} | 48 +------------- 2 files changed, 78 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/ci-pr.yml rename .github/workflows/{ci.yml => ci-push.yml} (74%) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml new file mode 100644 index 0000000..a3273ef --- /dev/null +++ b/.github/workflows/ci-pr.yml @@ -0,0 +1,76 @@ +name: CI (pull_request) + +on: + pull_request: + branches: [ main, develop ] + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + build: + name: Build & Test (Java ${{ matrix.java }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: [ '17', '21' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + cache: 'maven' + + - name: Build and test with Maven + run: mvn -B clean verify -Pqa -Ddependency-check.skip=true + + integration-tests: + name: Integration & E2E Tests (Java ${{ matrix.java }}) + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + java: [ '17', '21' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + cache: 'maven' + + - name: Run integration tests + run: mvn -B clean verify -Pit -Ddependency-check.skip=true + + - name: Upload IT test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: it-test-results-java-${{ matrix.java }} + path: | + **/target/failsafe-reports/ + retention-days: 7 + + - name: Publish IT Test Report + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: '**/target/failsafe-reports/TEST-*.xml' + check_name: IT Test Report (Java ${{ matrix.java }}) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-push.yml similarity index 74% rename from .github/workflows/ci.yml rename to .github/workflows/ci-push.yml index 0282b17..4a990d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-push.yml @@ -1,10 +1,8 @@ -name: CI +name: CI (push) on: push: branches: [ main, develop, 'feature/**' ] - pull_request: - branches: [ main, develop ] permissions: contents: read @@ -62,47 +60,6 @@ jobs: report_paths: '**/target/*-reports/TEST-*.xml' check_name: Test Report (Java ${{ matrix.java }}) - integration-tests: - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - strategy: - fail-fast: false - matrix: - java: [ '17', '21' ] - name: "Integration & E2E Tests (Java ${{ matrix.java }})" - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Set up JDK ${{ matrix.java }} - uses: actions/setup-java@v4 - with: - java-version: ${{ matrix.java }} - distribution: 'temurin' - cache: 'maven' - - - name: Run integration tests - run: mvn -B clean verify -Pit -Ddependency-check.skip=true - - - name: Upload IT test results - uses: actions/upload-artifact@v4 - if: always() - with: - name: it-test-results-java-${{ matrix.java }} - path: | - **/target/failsafe-reports/ - retention-days: 7 - - - name: Publish IT Test Report - uses: mikepenz/action-junit-report@v4 - if: always() - with: - report_paths: '**/target/failsafe-reports/TEST-*.xml' - check_name: IT Test Report (Java ${{ matrix.java }}) - quality: name: Code Quality Analysis runs-on: ubuntu-latest @@ -183,6 +140,5 @@ jobs: if: always() with: name: dependency-check-report - path: | - target/dependency-check-report.html + path: target/dependency-check-report.html retention-days: 30 From 183ef26c86f754ee82179234a186fa1ddf2cae73 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 20:07:24 +0100 Subject: [PATCH 08/12] Add condition to run `build` job only on push events --- .github/workflows/ci-push.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci-push.yml b/.github/workflows/ci-push.yml index 4a990d6..e65732e 100644 --- a/.github/workflows/ci-push.yml +++ b/.github/workflows/ci-push.yml @@ -11,6 +11,7 @@ permissions: jobs: build: + if: github.event.pull_request == null name: Build & Test (Java ${{ matrix.java }}) runs-on: ubuntu-latest strategy: From 19196b6781d9a8b48722d84f29a5a70e8c692dfe Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 20:27:33 +0100 Subject: [PATCH 09/12] Simplify CI workflows by consolidating and cleaning up configurations - Removed redundant jobs and permissions from `ci-push.yml` and `ci-pr.yml`. - Standardized artifact names for unit tests and integration test reports. - Enhanced modularity with streamlined code quality and dependency check steps. --- .github/workflows/ci-pr.yml | 88 +++++++++++++++++++++++++--- .github/workflows/ci-push.yml | 105 +--------------------------------- 2 files changed, 82 insertions(+), 111 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index a3273ef..1d4b9ef 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -6,8 +6,6 @@ on: permissions: contents: read - checks: write - pull-requests: write jobs: build: @@ -34,6 +32,15 @@ jobs: - name: Build and test with Maven run: mvn -B clean verify -Pqa -Ddependency-check.skip=true + - name: Upload unit test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: surefire-reports-java-${{ matrix.java }} + path: | + **/target/surefire-reports/ + retention-days: 7 + integration-tests: name: Integration & E2E Tests (Java ${{ matrix.java }}) runs-on: ubuntu-latest @@ -63,14 +70,81 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: it-test-results-java-${{ matrix.java }} + name: failsafe-reports-java-${{ matrix.java }} path: | **/target/failsafe-reports/ retention-days: 7 - - name: Publish IT Test Report - uses: mikepenz/action-junit-report@v4 + quality: + name: Code Quality Analysis + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Install artifacts for analysis (skip tests) + run: mvn -B -Ddependency-check.skip=true clean install -Pqa -DskipTests + + - name: Run SpotBugs analysis + run: mvn -B spotbugs:check -Pqa -Ddependency-check.skip=true + + - name: Run Checkstyle analysis + run: mvn -B checkstyle:check -Pqa -Ddependency-check.skip=true + + - name: Upload quality reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: quality-reports + path: | + **/target/spotbugsXml.xml + **/target/checkstyle-result.xml + retention-days: 7 + + dependency-check: + name: OWASP Dependency Check + runs-on: ubuntu-latest + needs: build + env: + NVD_API_KEY: ${{ secrets.NVD_API_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Cache Dependency-Check DB + uses: actions/cache@v4 + with: + path: ~/.m2/repository/org/owasp/dependency-check-data + key: depcheck-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} + restore-keys: | + depcheck-${{ runner.os }}- + + - name: Run OWASP Dependency Check + run: mvn -B dependency-check:aggregate -Pqa + + - name: Upload Dependency Check report + uses: actions/upload-artifact@v4 if: always() with: - report_paths: '**/target/failsafe-reports/TEST-*.xml' - check_name: IT Test Report (Java ${{ matrix.java }}) + name: dependency-check-report + path: target/dependency-check-report.html + retention-days: 30 diff --git a/.github/workflows/ci-push.yml b/.github/workflows/ci-push.yml index e65732e..1a43856 100644 --- a/.github/workflows/ci-push.yml +++ b/.github/workflows/ci-push.yml @@ -6,12 +6,9 @@ on: permissions: contents: read - checks: write - pull-requests: write jobs: build: - if: github.event.pull_request == null name: Build & Test (Java ${{ matrix.java }}) runs-on: ubuntu-latest strategy: @@ -39,107 +36,7 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: test-results-java-${{ matrix.java }} + name: surefire-reports-java-${{ matrix.java }} path: | **/target/surefire-reports/ retention-days: 7 - - - name: Upload coverage report - uses: actions/upload-artifact@v4 - if: matrix.java == '21' - with: - name: coverage-report - path: | - **/target/site/jacoco/ - **/target/jacoco.exec - retention-days: 7 - - - name: Publish Test Report - uses: mikepenz/action-junit-report@v4 - if: always() - with: - report_paths: '**/target/*-reports/TEST-*.xml' - check_name: Test Report (Java ${{ matrix.java }}) - - quality: - name: Code Quality Analysis - runs-on: ubuntu-latest - needs: build - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'maven' - - - name: Install artifacts for analysis - run: mvn -B -Ddependency-check.skip=true clean install -Pqa -DskipTests - - - name: Run SpotBugs analysis - run: mvn -B spotbugs:check -Pqa -Ddependency-check.skip=true - continue-on-error: true - - - name: Run Checkstyle analysis - run: mvn -B checkstyle:check -Pqa -Ddependency-check.skip=true - continue-on-error: true - - - name: Upload SpotBugs report - uses: actions/upload-artifact@v4 - if: always() - with: - name: spotbugs-report - path: '**/target/spotbugsXml.xml' - retention-days: 7 - - - name: Upload Checkstyle report - uses: actions/upload-artifact@v4 - if: always() - with: - name: checkstyle-report - path: '**/target/checkstyle-result.xml' - retention-days: 7 - - dependency-check: - name: OWASP Dependency Check - runs-on: ubuntu-latest - needs: build - env: - NVD_API_KEY: ${{ secrets.NVD_API_KEY }} - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - cache: 'maven' - - - name: Cache Dependency-Check DB - uses: actions/cache@v4 - with: - path: ~/.m2/repository/org/owasp/dependency-check-data - key: depcheck-${{ runner.os }}-${{ hashFiles('**/pom.xml') }} - restore-keys: | - depcheck-${{ runner.os }}- - - - name: Run OWASP Dependency Check - run: mvn -B dependency-check:aggregate -Pqa - continue-on-error: true - - - name: Upload Dependency Check report - uses: actions/upload-artifact@v4 - if: always() - with: - name: dependency-check-report - path: target/dependency-check-report.html - retention-days: 30 From 2b1c33f02c76a3970ea07755256f77c5733bc026 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 20:34:10 +0100 Subject: [PATCH 10/12] Enhance CI workflows with modular test reporting - Standardized artifact naming for both unit and integration test XMLs. - Introduced dedicated jobs for publishing test reports (`unit-report`, `reports`) with consolidated XML data. - Added report paths and check configurations to improve workflow clarity and feedback. --- .github/workflows/ci-pr.yml | 67 ++++++++++++++++++++++++----------- .github/workflows/ci-push.yml | 33 +++++++++++++++-- 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 1d4b9ef..05fc640 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -6,6 +6,8 @@ on: permissions: contents: read + checks: write + pull-requests: write jobs: build: @@ -32,13 +34,14 @@ jobs: - name: Build and test with Maven run: mvn -B clean verify -Pqa -Ddependency-check.skip=true - - name: Upload unit test results + - name: Upload unit test XMLs uses: actions/upload-artifact@v4 if: always() with: - name: surefire-reports-java-${{ matrix.java }} + name: unit-xml-java-${{ matrix.java }} path: | - **/target/surefire-reports/ + **/target/surefire-reports/TEST-*.xml + **/target/*-reports/TEST-*.xml retention-days: 7 integration-tests: @@ -66,13 +69,13 @@ jobs: - name: Run integration tests run: mvn -B clean verify -Pit -Ddependency-check.skip=true - - name: Upload IT test results + - name: Upload IT test XMLs uses: actions/upload-artifact@v4 if: always() with: - name: failsafe-reports-java-${{ matrix.java }} + name: it-xml-java-${{ matrix.java }} path: | - **/target/failsafe-reports/ + **/target/failsafe-reports/TEST-*.xml retention-days: 7 quality: @@ -102,16 +105,6 @@ jobs: - name: Run Checkstyle analysis run: mvn -B checkstyle:check -Pqa -Ddependency-check.skip=true - - name: Upload quality reports - uses: actions/upload-artifact@v4 - if: always() - with: - name: quality-reports - path: | - **/target/spotbugsXml.xml - **/target/checkstyle-result.xml - retention-days: 7 - dependency-check: name: OWASP Dependency Check runs-on: ubuntu-latest @@ -141,10 +134,42 @@ jobs: - name: Run OWASP Dependency Check run: mvn -B dependency-check:aggregate -Pqa - - name: Upload Dependency Check report - uses: actions/upload-artifact@v4 + reports: + name: Test Reports + runs-on: ubuntu-latest + needs: [ build, integration-tests ] + if: always() + + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: Download unit XMLs (all Java versions) + uses: actions/download-artifact@v4 + with: + pattern: unit-xml-java-* + merge-multiple: true + path: reports/unit + + - name: Download IT XMLs (all Java versions) + uses: actions/download-artifact@v4 + with: + pattern: it-xml-java-* + merge-multiple: true + path: reports/it + + - name: Publish Unit Test Report + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: 'reports/unit/**/TEST-*.xml' + check_name: Unit Test Report + + - name: Publish IT Test Report + uses: mikepenz/action-junit-report@v4 if: always() with: - name: dependency-check-report - path: target/dependency-check-report.html - retention-days: 30 + report_paths: 'reports/it/**/TEST-*.xml' + check_name: IT Test Report diff --git a/.github/workflows/ci-push.yml b/.github/workflows/ci-push.yml index 1a43856..ddd4e2a 100644 --- a/.github/workflows/ci-push.yml +++ b/.github/workflows/ci-push.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + checks: write jobs: build: @@ -32,11 +33,37 @@ jobs: - name: Build and test with Maven run: mvn -B clean verify -Pqa -Ddependency-check.skip=true - - name: Upload test results + - name: Upload unit test XMLs uses: actions/upload-artifact@v4 if: always() with: - name: surefire-reports-java-${{ matrix.java }} + name: unit-xml-java-${{ matrix.java }} path: | - **/target/surefire-reports/ + **/target/surefire-reports/TEST-*.xml + **/target/*-reports/TEST-*.xml retention-days: 7 + + unit-report: + name: Unit Test Report + runs-on: ubuntu-latest + needs: build + if: always() + + permissions: + contents: read + checks: write + + steps: + - name: Download unit XMLs (all Java versions) + uses: actions/download-artifact@v4 + with: + pattern: unit-xml-java-* + merge-multiple: true + path: reports/unit + + - name: Publish Unit Test Report + uses: mikepenz/action-junit-report@v4 + if: always() + with: + report_paths: 'reports/unit/**/TEST-*.xml' + check_name: Unit Test Report From 7688f0de73e54bfa0b1c0fc13dd8fa8dfca404d6 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 20:41:00 +0100 Subject: [PATCH 11/12] Remove `merge-multiple` flag from artifact download steps in `ci-pr.yml` --- .github/workflows/ci-pr.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 05fc640..6a87092 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -150,14 +150,12 @@ jobs: uses: actions/download-artifact@v4 with: pattern: unit-xml-java-* - merge-multiple: true path: reports/unit - name: Download IT XMLs (all Java versions) uses: actions/download-artifact@v4 with: pattern: it-xml-java-* - merge-multiple: true path: reports/it - name: Publish Unit Test Report From 84061d0706edea8e90a90f6661a8796d26f05875 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 12 Jan 2026 20:50:20 +0100 Subject: [PATCH 12/12] Restrict test XML uploads and downloads to Java 21 in `ci-pr.yml` to prevent report double-counting. --- .github/workflows/ci-pr.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 6a87092..91961ff 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -34,11 +34,12 @@ jobs: - name: Build and test with Maven run: mvn -B clean verify -Pqa -Ddependency-check.skip=true - - name: Upload unit test XMLs + # Upload XMLs ONLY once (Java 21) so the report doesn't double-count + - name: Upload unit test XMLs (Java 21 only) uses: actions/upload-artifact@v4 - if: always() + if: always() && matrix.java == '21' with: - name: unit-xml-java-${{ matrix.java }} + name: unit-xml path: | **/target/surefire-reports/TEST-*.xml **/target/*-reports/TEST-*.xml @@ -69,11 +70,12 @@ jobs: - name: Run integration tests run: mvn -B clean verify -Pit -Ddependency-check.skip=true - - name: Upload IT test XMLs + # Upload XMLs ONLY once (Java 21) so the report doesn't double-count + - name: Upload IT test XMLs (Java 21 only) uses: actions/upload-artifact@v4 - if: always() + if: always() && matrix.java == '21' with: - name: it-xml-java-${{ matrix.java }} + name: it-xml path: | **/target/failsafe-reports/TEST-*.xml retention-days: 7 @@ -146,16 +148,16 @@ jobs: pull-requests: write steps: - - name: Download unit XMLs (all Java versions) + - name: Download unit XMLs (Java 21 only) uses: actions/download-artifact@v4 with: - pattern: unit-xml-java-* + name: unit-xml path: reports/unit - - name: Download IT XMLs (all Java versions) + - name: Download IT XMLs (Java 21 only) uses: actions/download-artifact@v4 with: - pattern: it-xml-java-* + name: it-xml path: reports/it - name: Publish Unit Test Report