black = new SoftReference<>(JsonFactory.newObject("{\"foo\": 2}"));
+
+ EqualsVerifier.forClass(ThingMigrated.class)
+ .withRedefinedSuperclass()
+ .withPrefabValues(SoftReference.class, red, black)
+ .verify();
+ }
+
+ /**
+ * Tests if toJson correctly serializes a {@code ThingMigrated} event.
+ */
+ @Test
+ public void toJsonReturnsExpected() {
+ final ThingMigrated underTest =
+ ThingMigrated.of(TestConstants.Thing.THING,
+ TestConstants.Thing.REVISION_NUMBER,
+ TestConstants.TIMESTAMP,
+ TestConstants.EMPTY_DITTO_HEADERS,
+ TestConstants.METADATA);
+ final JsonObject actualJson = underTest.toJson(FieldType.notHidden());
+
+ assertThat(actualJson).isEqualTo(KNOWN_JSON);
+ }
+
+ /**
+ * Verifies that a {@code ThingMigrated} instance can be created from valid JSON.
+ */
+ @Test
+ public void createInstanceFromValidJson() {
+ final ThingMigrated underTest =
+ ThingMigrated.fromJson(KNOWN_JSON, TestConstants.EMPTY_DITTO_HEADERS);
+
+ assertThat(underTest).isNotNull();
+ assertThat((Object) underTest.getEntityId()).isEqualTo(TestConstants.Thing.THING_ID);
+ assertThat(underTest.getResourcePath()).isEqualTo(JsonPointer.empty());
+ assertThat(underTest.getMetadata()).contains(TestConstants.METADATA);
+ assertThat(underTest.getRevision()).isEqualTo(TestConstants.Thing.REVISION_NUMBER);
+ assertThat(underTest.getTimestamp()).contains(TestConstants.TIMESTAMP);
+ }
+}
diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java
new file mode 100644
index 0000000000..3aaa379432
--- /dev/null
+++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategy.java
@@ -0,0 +1,367 @@
+/*
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.things.service.persistence.actors.strategies.commands;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletionStage;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+import org.apache.pekko.actor.ActorSystem;
+import org.apache.pekko.japi.Pair;
+import org.eclipse.ditto.base.model.entity.metadata.Metadata;
+import org.eclipse.ditto.base.model.exceptions.InvalidRqlExpressionException;
+import org.eclipse.ditto.base.model.headers.DittoHeaders;
+import org.eclipse.ditto.base.model.headers.WithDittoHeaders;
+import org.eclipse.ditto.base.model.headers.entitytag.EntityTag;
+import org.eclipse.ditto.base.model.json.FieldType;
+import org.eclipse.ditto.internal.utils.persistentactors.results.Result;
+import org.eclipse.ditto.internal.utils.persistentactors.results.ResultFactory;
+import org.eclipse.ditto.json.JsonFactory;
+import org.eclipse.ditto.json.JsonField;
+import org.eclipse.ditto.json.JsonKey;
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.json.JsonObjectBuilder;
+import org.eclipse.ditto.json.JsonPointer;
+import org.eclipse.ditto.json.JsonValue;
+import org.eclipse.ditto.placeholders.PlaceholderFactory;
+import org.eclipse.ditto.placeholders.TimePlaceholder;
+import org.eclipse.ditto.policies.model.ResourceKey;
+import org.eclipse.ditto.rql.model.ParserException;
+import org.eclipse.ditto.rql.parser.RqlPredicateParser;
+import org.eclipse.ditto.rql.query.filter.QueryFilterCriteriaFactory;
+import org.eclipse.ditto.rql.query.things.ThingPredicateVisitor;
+import org.eclipse.ditto.things.model.FeatureDefinition;
+import org.eclipse.ditto.things.model.Thing;
+import org.eclipse.ditto.things.model.ThingId;
+import org.eclipse.ditto.things.model.ThingsModelFactory;
+import org.eclipse.ditto.things.model.signals.commands.ThingCommandSizeValidator;
+import org.eclipse.ditto.things.model.signals.commands.ThingResourceMapper;
+import org.eclipse.ditto.things.model.signals.commands.exceptions.SkeletonGenerationFailedException;
+import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition;
+import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse;
+import org.eclipse.ditto.things.model.signals.events.ThingEvent;
+import org.eclipse.ditto.things.model.signals.events.ThingMigrated;
+
+
+/**
+ * Strategy to handle the {@link MigrateThingDefinition} command.
+ *
+ * This strategy processes updates to a Thing's definition, applying necessary data migrations
+ * and ensuring that defaults are properly initialized when required.
+ *
+ *
Assumptions:
+ *
+ * - The {@link MigrateThingDefinition} command provides a ThingDefinition URL, which is used
+ * to create a skeleton Thing. The command's payload also includes migration data and patch
+ * conditions for fine-grained updates.
+ * - Patch conditions are evaluated using RQL-based expressions to determine which migration
+ * payload entries should be applied.
+ * - Skeleton generation extracts and merges Thing definitions and default values separately,
+ * ensuring a clear distinction between structural updates and default settings.
+ * - After applying skeleton-based modifications and migration payloads, the changes are merged.
+ * - Property initialization can be optionally enabled via the command, applying default values
+ * to the updated Thing when set to true.
+ * - The resulting Thing undergoes validation to ensure compliance with WoT model constraints
+ * before persisting changes.
+ *
+ */
+@Immutable
+public final class MigrateThingDefinitionStrategy extends AbstractThingModifyCommandStrategy {
+
+ private static final ThingResourceMapper> ENTITY_TAG_MAPPER =
+ ThingResourceMapper.from(EntityTagCalculator.getInstance());
+
+ private static final TimePlaceholder TIME_PLACEHOLDER = TimePlaceholder.getInstance();
+
+ /**
+ * Constructs a new {@code MigrateThingDefinitionStrategy} object.
+ *
+ * @param actorSystem the actor system to use for loading the WoT extension.
+ */
+ MigrateThingDefinitionStrategy(final ActorSystem actorSystem) {
+ super(MigrateThingDefinition.class, actorSystem);
+ }
+
+ @Override
+ protected Result> doApply(final Context context,
+ @Nullable final Thing thing,
+ final long nextRevision,
+ final MigrateThingDefinition command,
+ @Nullable final Metadata metadata) {
+
+ final Thing existingThing = getEntityOrThrow(thing);
+ final Instant eventTs = getEventTimestamp();
+ return handleMigrateDefinition(context, existingThing, eventTs, nextRevision, command, metadata);
+ }
+
+ private Result> handleMigrateDefinition(
+ final Context context,
+ final Thing existingThing,
+ final Instant eventTs,
+ final long nextRevision,
+ final MigrateThingDefinition command,
+ @Nullable final Metadata metadata) {
+
+ final DittoHeaders dittoHeaders = command.getDittoHeaders();
+ final boolean isDryRun = dittoHeaders.isDryRun();
+
+ // 1. Evaluate Patch Conditions and modify the migrationPayload
+ final JsonObject adjustedMigrationPayload = evaluatePatchConditions(
+ existingThing,
+ command.getMigrationPayload(),
+ command.getPatchConditions(),
+ dittoHeaders);
+
+ // 2. Generate Skeleton using definition and apply migration
+ final CompletionStage updatedThingStage = generateSkeleton(command, dittoHeaders)
+ .thenApply(skeleton -> resolveSkeletonConflicts(
+ existingThing, skeleton,
+ command.isInitializeMissingPropertiesFromDefaults()))
+ .thenApply(mergedThing -> applyMigrationPayload(context,
+ mergedThing, adjustedMigrationPayload, dittoHeaders, nextRevision, eventTs));
+
+ // 3. Validate and build event response
+ final CompletionStage> validatedStage = updatedThingStage
+ .thenCompose(mergedThing -> buildValidatedStage(command, existingThing, mergedThing)
+ .thenApply(migrateThingDefinition -> new Pair<>(mergedThing, migrateThingDefinition)));
+
+ // If Dry Run, return a simulated response without applying changes
+ if (isDryRun) {
+ return ResultFactory.newQueryResult(
+ command,
+ validatedStage.thenApply(pair ->
+ MigrateThingDefinitionResponse.dryRun(
+ context.getState(),
+ pair.first().toJson(),
+ dittoHeaders))
+ );
+ }
+
+ // 4. Apply migration and generate event
+ final CompletionStage> eventStage = validatedStage.thenApply(pair -> ThingMigrated.of(
+ pair.first().toBuilder()
+ .setId(context.getState())
+ .build(), nextRevision, eventTs, dittoHeaders,
+ metadata));
+
+ final CompletionStage responseStage = validatedStage.thenApply(pair ->
+ appendETagHeaderIfProvided(command, MigrateThingDefinitionResponse.applied(context.getState(),
+ pair.first().toJson(), dittoHeaders),
+ pair.first()));
+
+ return ResultFactory.newMutationResult(command, eventStage, responseStage);
+ }
+
+ private JsonObject evaluatePatchConditions(final Thing existingThing,
+ final JsonObject migrationPayload,
+ final Map patchConditions,
+ final DittoHeaders dittoHeaders) {
+ final JsonObjectBuilder adjustedPayloadBuilder = migrationPayload.toBuilder();
+
+ for (final Map.Entry entry : patchConditions.entrySet()) {
+ final ResourceKey resourceKey = entry.getKey();
+ final String conditionExpression = entry.getValue();
+
+ final boolean conditionMatches = evaluateCondition(existingThing, conditionExpression, dittoHeaders);
+
+ final JsonPointer resourcePointer = JsonFactory.newPointer(resourceKey.getResourcePath());
+ if (!conditionMatches && doesMigrationPayloadContainResourceKey(migrationPayload, resourcePointer)) {
+ adjustedPayloadBuilder.remove(resourcePointer);
+ }
+ }
+
+ return adjustedPayloadBuilder.build();
+ }
+
+ private static boolean doesMigrationPayloadContainResourceKey(final JsonObject migrationPayload,
+ final JsonPointer pointer) {
+ return migrationPayload.getValue(pointer).isPresent();
+ }
+
+ private boolean evaluateCondition(final Thing existingThing,
+ final String conditionExpression,
+ final DittoHeaders dittoHeaders) {
+ try {
+ final var criteria = QueryFilterCriteriaFactory
+ .modelBased(RqlPredicateParser.getInstance())
+ .filterCriteria(conditionExpression, dittoHeaders);
+
+ final var predicate = ThingPredicateVisitor.apply(criteria,
+ PlaceholderFactory.newPlaceholderResolver(TIME_PLACEHOLDER, new Object()));
+
+ return predicate.test(existingThing);
+ } catch (final ParserException | IllegalArgumentException e) {
+ throw InvalidRqlExpressionException.newBuilder()
+ .message(e.getMessage())
+ .cause(e)
+ .dittoHeaders(dittoHeaders)
+ .build();
+ }
+ }
+
+ private CompletionStage generateSkeleton(
+ final MigrateThingDefinition command,
+ final DittoHeaders dittoHeaders) {
+ return wotThingSkeletonGenerator.provideThingSkeletonForCreation(
+ command.getEntityId(),
+ ThingsModelFactory.newDefinition(command.getThingDefinitionUrl()),
+ dittoHeaders
+ )
+ .thenApply(optionalSkeleton -> {
+ Thing skeleton = optionalSkeleton.orElseThrow(() ->
+ SkeletonGenerationFailedException.newBuilder(command.getEntityId())
+ .dittoHeaders(command.getDittoHeaders())
+ .build()
+ );
+
+ return skeleton.toBuilder()
+ .setDefinition(ThingsModelFactory.newDefinition(command.getThingDefinitionUrl()))
+ .build();
+ });
+ }
+
+
+
+ private Thing extractDefinitions(final Thing thing) {
+ var thingBuilder = ThingsModelFactory.newThingBuilder();
+ thing.getFeatures().orElseGet(ThingsModelFactory::emptyFeatures).forEach(feature -> {
+ FeatureDefinition featureDefinition = feature.getDefinition().orElse(null);
+ thingBuilder.setFeature(feature.getId(), featureDefinition, null);
+ });
+
+ return thingBuilder.build();
+ }
+
+
+ /**
+ * Resolves conflicts between a skeleton Thing and an existing Thing while optionally initializing properties.
+ * If initialization is disabled, only definitions from the skeleton are extracted. Otherwise, conflicting
+ * fields are removed, and a new Thing is created with the refined values.
+ *
+ * @param existingThing The existing Thing to compare against.
+ * @param skeletonThing The skeleton Thing containing default values.
+ * @param isInitializeProperties A flag indicating whether properties should be initialized.
+ * @return A new Thing with conflicts resolved and properties optionally initialized.
+ */
+ private Thing resolveSkeletonConflicts(final Thing existingThing, final Thing skeletonThing,
+ final boolean isInitializeProperties) {
+
+ if (!isInitializeProperties) {
+ return extractDefinitions(skeletonThing);
+ }
+
+ final var refinedDefaults = removeConflicts(skeletonThing.toJson(), existingThing.toJson().asObject());
+
+ return ThingsModelFactory.newThing(refinedDefaults);
+ }
+
+
+ /**
+ * Removes conflicting fields from the default values by recursively comparing them with existing values.
+ * Fields containing "definition" are always retained. If a field exists in both JSON objects and is a nested
+ * object, the function will recursively filter out conflicting values. If a field does not exist in the
+ * existing values, it is retained from the default values.
+ *
+ * @param defaultValues The JsonObject containing the default values.
+ * @param existingValues The JsonObject containing the existing values to compare against.
+ * @return A new JsonObject with conflicts removed, preserving necessary fields.
+ */
+ public static JsonObject removeConflicts(final JsonObject defaultValues, final JsonObject existingValues) {
+ final JsonObjectBuilder builder = JsonFactory.newObjectBuilder();
+
+ if (defaultValues.isNull() && existingValues.isNull()) {
+ return JsonFactory.nullObject();
+ }
+
+ for (JsonField field : defaultValues) {
+ final JsonKey key = field.getKey();
+ final JsonValue defaultValue = field.getValue();
+ final Optional maybeExistingValue = existingValues.getValue(key);
+
+ if (key.toString().contains("definition")) {
+ builder.set(key, defaultValue);
+ continue;
+ }
+
+ maybeExistingValue.flatMap(existingValue -> resolveConflictingValues(defaultValue, existingValue))
+ .ifPresentOrElse(
+ resolvedValue -> builder.set(key, resolvedValue),
+ () -> builder.set(field)
+ );
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Resolves conflicting JsonValue objects by recursively comparing them.
+ * If both values are JsonObjects, it calls {@link #removeConflicts(JsonObject, JsonObject)}
+ * to recursively filter out conflicting values. Otherwise, it returns an empty Optional,
+ * indicating that the value should not be retained.
+ *
+ * @param defaultValue The JsonValue from the default values object.
+ * @param existingValue The JsonValue from the existing values object.
+ * @return An Optional containing a filtered JsonObject if both values are objects; otherwise, an empty Optional.
+ */
+ private static Optional resolveConflictingValues(final JsonValue defaultValue, final JsonValue existingValue) {
+ return (defaultValue.isObject() && existingValue.isObject())
+ ? Optional.of(removeConflicts(defaultValue.asObject(), existingValue.asObject()))
+ : Optional.empty();
+ }
+
+
+
+ private Thing applyMigrationPayload(final Context context, final Thing thing,
+ final JsonObject migrationPayload,
+ final DittoHeaders dittoHeaders,
+ final long nextRevision,
+ final Instant eventTs) {
+ final JsonObject thingJson = thing.toJson(FieldType.all());
+ final JsonObject mergedJson = JsonFactory.newObject(migrationPayload, thingJson);
+ context.getLog().debug("Thing updated from migrated JSON: {}", mergedJson);
+ ThingCommandSizeValidator.getInstance().ensureValidSize(
+ mergedJson::getUpperBoundForStringSize,
+ () -> mergedJson.toString().length(),
+ () -> dittoHeaders);
+
+ return ThingsModelFactory.newThingBuilder(mergedJson)
+ .setModified(eventTs)
+ .setRevision(nextRevision)
+ .build();
+ }
+
+ @Override
+ public Optional previousEntityTag(final MigrateThingDefinition command,
+ @Nullable final Thing previousEntity) {
+ return ENTITY_TAG_MAPPER.map(JsonPointer.empty(), previousEntity);
+ }
+
+ @Override
+ public Optional nextEntityTag(final MigrateThingDefinition command, @Nullable final Thing newEntity) {
+ return ENTITY_TAG_MAPPER.map(JsonPointer.empty(), getEntityOrThrow(newEntity));
+ }
+
+ @Override
+ protected CompletionStage performWotValidation(final MigrateThingDefinition command,
+ @Nullable final Thing previousThing, @Nullable final Thing previewThing) {
+ return wotThingModelValidator.validateThing(
+ Optional.ofNullable(previewThing).orElseThrow(),
+ command.getResourcePath(),
+ command.getDittoHeaders()
+ ).thenApply(aVoid -> command);
+ }
+}
diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java
index 6aeca0f6b6..3187dbf64a 100644
--- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java
+++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/ThingCommandStrategies.java
@@ -87,6 +87,7 @@ private void addThingStrategies(final ActorSystem system) {
addStrategy(new RetrieveThingStrategy(system));
addStrategy(new DeleteThingStrategy(system));
addStrategy(new MergeThingStrategy(system));
+ addStrategy(new MigrateThingDefinitionStrategy(system));
}
private void addPolicyStrategies(final ActorSystem system) {
diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java
index 42e6ca2279..94fb717232 100644
--- a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java
+++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingEventStrategies.java
@@ -51,6 +51,7 @@
import org.eclipse.ditto.things.model.signals.events.ThingDeleted;
import org.eclipse.ditto.things.model.signals.events.ThingEvent;
import org.eclipse.ditto.things.model.signals.events.ThingMerged;
+import org.eclipse.ditto.things.model.signals.events.ThingMigrated;
import org.eclipse.ditto.things.model.signals.events.ThingModified;
/**
@@ -86,6 +87,7 @@ private void addThingStrategies() {
addStrategy(ThingModified.class, new ThingModifiedStrategy());
addStrategy(ThingDeleted.class, new ThingDeletedStrategy());
addStrategy(ThingMerged.class, new ThingMergedStrategy());
+ addStrategy(ThingMigrated.class, new ThingMigratedStrategy());
}
private void addAttributesStrategies() {
diff --git a/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java
new file mode 100644
index 0000000000..dda0d26284
--- /dev/null
+++ b/things/service/src/main/java/org/eclipse/ditto/things/service/persistence/actors/strategies/events/ThingMigratedStrategy.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.things.service.persistence.actors.strategies.events;
+
+import org.eclipse.ditto.base.model.json.FieldType;
+import org.eclipse.ditto.json.JsonFactory;
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.things.model.Thing;
+import org.eclipse.ditto.things.model.ThingLifecycle;
+import org.eclipse.ditto.things.model.ThingsModelFactory;
+import org.eclipse.ditto.things.model.signals.events.ThingMigrated;
+
+import javax.annotation.Nullable;
+import javax.annotation.concurrent.Immutable;
+
+/**
+ * This strategy handles the {@link ThingMigrated} event.
+ */
+@Immutable
+final class ThingMigratedStrategy extends AbstractThingEventStrategy {
+
+ ThingMigratedStrategy() {
+ super();
+ }
+
+ @Nullable
+ @Override
+ public Thing handle(final ThingMigrated event, @Nullable final Thing thing, final long revision) {
+ if (null != thing) {
+ final JsonObject jsonObject = thing.toJson(FieldType.all());
+ final JsonObject mergePatch = event.getThing().toJson();
+ final JsonObject mergedJson = JsonFactory.mergeJsonValues(mergePatch, jsonObject).asObject();
+ return ThingsModelFactory.newThingBuilder(mergedJson)
+ .setRevision(revision)
+ .setModified(event.getTimestamp().orElse(null))
+ .setLifecycle(ThingLifecycle.ACTIVE)
+ .setMetadata(mergeMetadata(thing, event))
+ .build();
+ } else {
+ return null;
+ }
+ }
+
+}
diff --git a/things/service/src/main/resources/things-dev.conf b/things/service/src/main/resources/things-dev.conf
index 9f7d77d3c0..61652919ac 100755
--- a/things/service/src/main/resources/things-dev.conf
+++ b/things/service/src/main/resources/things-dev.conf
@@ -56,7 +56,7 @@ ditto {
wot {
tm-model-validation {
- enabled = true
+ enabled = false
dynamic-configuration = [
{
diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java
index 80f9c58f9f..4cf424a6db 100644
--- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java
+++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/ETagTestUtils.java
@@ -14,6 +14,7 @@
import javax.annotation.Nullable;
+import org.eclipse.ditto.base.model.common.HttpStatus;
import org.eclipse.ditto.base.model.entity.Revision;
import org.eclipse.ditto.base.model.headers.DittoHeaderDefinition;
import org.eclipse.ditto.base.model.headers.DittoHeaders;
@@ -58,6 +59,7 @@
import org.eclipse.ditto.things.model.signals.commands.query.RetrievePolicyIdResponse;
import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThingDefinitionResponse;
import org.eclipse.ditto.things.model.signals.commands.query.RetrieveThingResponse;
+import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse;
/**
* Provides methods to get command responses that include the correct eTag header value.
@@ -361,6 +363,19 @@ public static RetrieveThingDefinitionResponse retrieveDefinitionResponse(final T
return RetrieveThingDefinitionResponse.of(thingId, expectedThingDefinition, dittoHeadersWithETag);
}
+ public static MigrateThingDefinitionResponse migrateThingDefinitionResponse(final ThingId thingId,
+ final JsonObject patch, final Thing mergeThing, final DittoHeaders dittoHeaders) {
+
+ final DittoHeaders dittoHeadersWithETag = appendEntityIdAndETagToDittoHeaders(thingId, mergeThing, dittoHeaders);
+
+ return MigrateThingDefinitionResponse.newInstance(
+ thingId,
+ patch,
+ MigrateThingDefinitionResponse.MergeStatus.APPLIED,
+ HttpStatus.OK,
+ dittoHeadersWithETag
+ );
+ }
protected static DittoHeaders appendEntityIdAndETagToDittoHeaders(final ThingId thingId,
final Object object,
diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java
index 396fbbd541..5961d6aad5 100644
--- a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java
+++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/AbstractCommandStrategyTest.java
@@ -72,9 +72,9 @@ public abstract class AbstractCommandStrategyTest {
@BeforeClass
public static void initTestConstants() {
logger = Mockito.mock(DittoDiagnosticLoggingAdapter.class);
- Mockito.when(logger.withCorrelationId(Mockito.any(DittoHeaders.class))).thenReturn(logger);
- Mockito.when(logger.withCorrelationId(Mockito.any(WithDittoHeaders.class))).thenReturn(logger);
- Mockito.when(logger.withCorrelationId(Mockito.any(CharSequence.class))).thenReturn(logger);
+ Mockito.lenient().when(logger.withCorrelationId(Mockito.any(DittoHeaders.class))).thenReturn(logger);
+ Mockito.lenient().when(logger.withCorrelationId(Mockito.any(WithDittoHeaders.class))).thenReturn(logger);
+ Mockito.lenient().when(logger.withCorrelationId(Mockito.any(CharSequence.class))).thenReturn(logger);
}
protected static CommandStrategy.Context getDefaultContext() {
@@ -202,7 +202,7 @@ private static > T assertModificationResult(fina
return event.getValue();
}
- private static > T assertStagedModificationResult(final Result> result,
+ protected static > T assertStagedModificationResult(final Result> result,
final Class eventClazz,
final WithDittoHeaders expectedResponse,
final boolean becomeDeleted) {
diff --git a/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java
new file mode 100644
index 0000000000..7ceeadeee5
--- /dev/null
+++ b/things/service/src/test/java/org/eclipse/ditto/things/service/persistence/actors/strategies/commands/MigrateThingDefinitionStrategyTest.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information regarding copyright ownership.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.ditto.things.service.persistence.actors.strategies.commands;
+
+import static org.eclipse.ditto.things.model.TestConstants.Thing.THING_V2;
+
+
+import com.typesafe.config.ConfigFactory;
+
+import org.apache.pekko.actor.ActorSystem;
+import org.eclipse.ditto.base.model.headers.DittoHeaders;
+import org.eclipse.ditto.internal.utils.persistentactors.commands.CommandStrategy;
+import org.eclipse.ditto.internal.utils.persistentactors.results.Result;
+import org.eclipse.ditto.json.JsonFactory;
+import org.eclipse.ditto.json.JsonObject;
+import org.eclipse.ditto.things.model.Thing;
+import org.eclipse.ditto.things.model.ThingId;
+import org.eclipse.ditto.things.model.ThingRevision;
+import org.eclipse.ditto.things.model.ThingsModelFactory;
+import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinition;
+import org.eclipse.ditto.things.model.signals.commands.modify.MigrateThingDefinitionResponse;
+import org.eclipse.ditto.things.model.signals.events.ThingEvent;
+import org.eclipse.ditto.things.model.signals.events.ThingMigrated;
+import org.eclipse.ditto.things.service.persistence.actors.ETagTestUtils;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit test for {@link MigrateThingDefinitionStrategy} with injected mock of WotThingSkeletonGenerator.
+ */
+public final class MigrateThingDefinitionStrategyTest extends AbstractCommandStrategyTest {
+
+ private MigrateThingDefinitionStrategy underTest;
+
+ @Before
+ public void setUp() throws Exception {
+ final ActorSystem actorSystem = ActorSystem.create("test", ConfigFactory.load("test"));
+ underTest = new MigrateThingDefinitionStrategy(actorSystem);
+ }
+
+
+ @Test
+ public void migrateExistingThing() {
+ final CommandStrategy.Context context = getDefaultContext();
+ final ThingId thingId = context.getState();
+ final Thing existingThing = THING_V2.toBuilder().setRevision(NEXT_REVISION - 1).build();
+
+ final JsonObject migrationPayload = JsonFactory.newObjectBuilder()
+ .set("attributes", JsonFactory.newObjectBuilder().set("manufacturer", "New Corp").build())
+ .build();
+
+ final String thingDefinitionUrl = "https://eclipse-ditto.github.io/ditto-examples/wot/models/dimmable-colored-lamp-1.0.0.tm.jsonld";
+
+ final MigrateThingDefinition command = MigrateThingDefinition.of(
+ thingId,
+ thingDefinitionUrl,
+ migrationPayload,
+ null,
+ true,
+ DittoHeaders.empty()
+ );
+
+ final MigrateThingDefinitionResponse expectedResponse = ETagTestUtils.migrateThingDefinitionResponse(thingId,
+ getThingJson(thingDefinitionUrl),
+ getMergedThing(thingDefinitionUrl),
+ command.getDittoHeaders());
+
+ final Result> result = underTest.apply(context, existingThing, NEXT_REVISION, command);
+
+ result.mapStages(completionStage -> {
+ completionStage.toCompletableFuture().join();
+ return completionStage;
+ });
+
+ assertStagedModificationResult(result, ThingMigrated.class, expectedResponse, false);
+ }
+
+ private static JsonObject getThingJson(String thingDefinitionUrl) {
+ return JsonFactory.newObjectBuilder()
+ .set("definition", thingDefinitionUrl)
+ .set("attributes", JsonFactory.newObjectBuilder()
+ .set("manufacturer", "New Corp")
+ .set("on", false)
+ .set("color", JsonFactory.newObjectBuilder()
+ .set("r", 0)
+ .set("g", 0)
+ .set("b", 0)
+ .build())
+ .set("dimmer-level", 0.0)
+ .build())
+ .build();
+ }
+
+
+ private Thing getMergedThing(final String thingDefinitionUrl) {
+ return ThingsModelFactory.newThingBuilder(getThingJson(thingDefinitionUrl))
+ .setRevision(ThingRevision.newInstance(NEXT_REVISION))
+ .build();
+ }
+
+}