This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new a843e7a6e UNOMI-943: Fix 3.1.0 migration resume and document script
authoring (#810)
a843e7a6e is described below
commit a843e7a6e06d3b3174aee4493dec508ea6220771
Author: Serge Huber <[email protected]>
AuthorDate: Tue Jul 7 21:31:37 2026 +0200
UNOMI-943: Fix 3.1.0 migration resume and document script authoring (#810)
Migration cleanup merge
---
.../apache/unomi/itests/migration/MigrationIT.java | 39 ++++
.../migrate-12.0.0-01-nestedStepPitfall.groovy | 35 ++++
.../asciidoc/migrations/migrate-3.0-to-3.1.adoc | 2 +
.../src/main/asciidoc/migrations/migrations.adoc | 4 +
.../migrations/writing-migration-scripts.adoc | 207 +++++++++++++++++++++
manual/src/main/asciidoc/shell-commands.adoc | 2 +-
.../shell/migration/utils/MigrationUtils.java | 9 +-
.../migrate-3.1.0-01-tenantDocumentIds.groovy | 55 +++---
.../shell/migration/utils/MigrationUtilsTest.java | 10 +
9 files changed, 333 insertions(+), 30 deletions(-)
diff --git
a/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java
b/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java
index b81df24f9..9c3b56262 100644
--- a/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/migration/MigrationIT.java
@@ -36,6 +36,8 @@ public class MigrationIT extends BaseIT {
private static final String SUCCESS_SCRIPT_NAME =
"migrate-11.0.0-01-successMigration.groovy";
private static final String FAILING_SCRIPT_RESOURCE = "migration/" +
FAILING_SCRIPT_NAME;
private static final String SUCCESS_SCRIPT_RESOURCE = "migration/" +
SUCCESS_SCRIPT_NAME;
+ private static final String NESTED_STEP_SCRIPT_NAME =
"migrate-12.0.0-01-nestedStepPitfall.groovy";
+ private static final String NESTED_STEP_SCRIPT_RESOURCE = "migration/" +
NESTED_STEP_SCRIPT_NAME;
@Test
public void checkMigrationRecoverySystem() throws Exception {
@@ -44,6 +46,7 @@ public class MigrationIT extends BaseIT {
LOGGER.info("Karaf data directory: {}", karafData);
Path scriptsDirectory = Paths.get(karafData, "migration", "scripts");
+ Path historyFsPath = Paths.get(karafData, "migration", "history.json");
Path failingScriptFsPath = Paths.get(karafData, "migration",
"scripts", FAILING_SCRIPT_NAME);
Path successScriptFsPath = Paths.get(karafData, "migration",
"scripts", SUCCESS_SCRIPT_NAME);
@@ -74,6 +77,42 @@ public class MigrationIT extends BaseIT {
} finally {
Files.deleteIfExists(failingScriptFsPath);
Files.deleteIfExists(successScriptFsPath);
+ Files.deleteIfExists(historyFsPath);
+ }
+ }
+
+ /**
+ * Regression test for UNOMI-943: a step registered inside another step's
closure
+ * is never (re-)registered once the outer step is already marked
COMPLETED in history.
+ * We simulate that prior state directly by seeding history.json, then
verify that the
+ * nested step never runs while a sibling top-level step still does.
+ */
+ @Test
+ public void checkNestedMigrationStepPitfall() throws Exception {
+ String karafData = super.karafData();
+ Path scriptFsPath = Paths.get(karafData, "migration", "scripts",
NESTED_STEP_SCRIPT_NAME);
+ Path historyFsPath = Paths.get(karafData, "migration", "history.json");
+
+ try {
+ Files.createDirectories(scriptFsPath.getParent());
+ Files.write(scriptFsPath,
bundleResourceAsString(NESTED_STEP_SCRIPT_RESOURCE).getBytes(StandardCharsets.UTF_8));
+
+ // Simulate "outer-step" already COMPLETED from a previous run,
before "nested-step" existed.
+ Files.write(historyFsPath,
"{\"outer-step\":\"COMPLETED\"}".getBytes(StandardCharsets.UTF_8));
+
+ String result = executeCommand("unomi:migrate 11.0.0 true");
+ System.out.println("Nested migration step pitfall result:");
+ System.out.println(result);
+
+ // Outer step is already COMPLETED, so its closure -- including
the nested step
+ // registration -- is never re-entered. This is the bug UNOMI-943
fixed by hoisting.
+ Assert.assertFalse(result.contains("inside outer-step"));
+ Assert.assertFalse(result.contains("inside nested-step"));
+ // A sibling top-level step is unaffected and still runs.
+ Assert.assertTrue(result.contains("inside sibling-step"));
+ } finally {
+ Files.deleteIfExists(scriptFsPath);
+ Files.deleteIfExists(historyFsPath);
}
}
}
diff --git
a/itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy
b/itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy
new file mode 100644
index 000000000..f5a75877f
--- /dev/null
+++
b/itests/src/test/resources/migration/migrate-12.0.0-01-nestedStepPitfall.groovy
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import org.apache.unomi.shell.migration.service.MigrationContext
+
+// Fixture for MigrationIT#checkNestedMigrationStepPitfall.
+// Reproduces the UNOMI-943 anti-pattern: a step nested inside another step's
+// closure is never (re-)registered once the outer step is already COMPLETED.
+
+MigrationContext context = migrationContext
+
+context.performMigrationStep("outer-step", () -> {
+ context.printMessage("inside outer-step")
+ context.performMigrationStep("nested-step", () -> {
+ context.printMessage("inside nested-step")
+ })
+})
+
+context.performMigrationStep("sibling-step", () -> {
+ context.printMessage("inside sibling-step")
+})
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
index 146f97510..92961678c 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
@@ -237,6 +237,8 @@ Before starting the migration, please ensure that:
The migration from 3.0 to 3.1 is primarily a configuration and authentication
update:
+Contributors maintaining the Groovy scripts that implement this upgrade should
follow <<_writing_migration_scripts,Writing migration scripts>> (step tracking,
idempotency, and common pitfalls).
+
1. **Shutdown your Apache Unomi 3.0 cluster**
2. **Update your client applications** to use the new authentication model
3. **Configure tenant-specific API keys** for your applications
diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc
b/manual/src/main/asciidoc/migrations/migrations.adoc
index 6e73631e8..b096abe8a 100644
--- a/manual/src/main/asciidoc/migrations/migrations.adoc
+++ b/manual/src/main/asciidoc/migrations/migrations.adoc
@@ -14,6 +14,10 @@
This section contains information and steps to migrate between major Unomi
versions.
+=== Writing migration scripts
+
+include::writing-migration-scripts.adoc[]
+
=== V2/V3 API Compatibility Guide
include::v2-v3-compatibility.adoc[]
diff --git a/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc
b/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc
new file mode 100644
index 000000000..b0615bb5d
--- /dev/null
+++ b/manual/src/main/asciidoc/migrations/writing-migration-scripts.adoc
@@ -0,0 +1,207 @@
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+[[_writing_migration_scripts]]
+=== Writing migration scripts
+
+This section is for **contributors** who add or change Groovy migration
scripts shipped in the `shell-commands` module.
+Operators upgrading a cluster should follow the version-specific guides in
this chapter instead.
+
+Migration scripts run with **Apache Unomi stopped**, via `unomi:migrate` (see
<<SSH Shell Commands>>).
+They talk directly to Elasticsearch or OpenSearch over HTTP and transform
persisted data in place.
+
+==== Script location and naming
+
+Bundled scripts live under:
+
+[source,text]
+----
+tools/shell-commands/src/main/resources/META-INF/cxs/migration/
+----
+
+Each file must match:
+
+[source,text]
+----
+migrate-<version>-<priority>-<name>.groovy
+----
+
+* `<version>` — three-part version the script belongs to (for example `3.1.0`)
+* `<priority>` — two-digit order within that version (`00`, `01`, `05`, …)
+* `<name>` — short camelCase label (for example `tenantDocumentIds`)
+
+Example: `migrate-3.1.0-01-tenantDocumentIds.groovy`.
+
+Scripts are sorted by version, then priority, then name.
+Use gaps in priority (`00`, `05`, `10`) when you may need to insert a step
later.
+
+Operators can also drop scripts under `{karaf.data}/migration/scripts/` for
one-off fixes; the same naming rules apply.
+
+==== Use `performMigrationStep` for every resumable unit of work
+
+`MigrationContext.performMigrationStep(stepKey, closure)` is the idempotency
and **crash-recovery** mechanism:
+
+* Each `stepKey` is persisted in `{karaf.data}/migration/history.json`.
+* If a step is already `COMPLETED`, it is skipped on the next run.
+* If a run fails mid-step, only steps not yet marked complete are executed
again.
+
+Rules:
+
+. **One logical outcome per step key** — for example “configure session
rollover alias”, not “do everything in one giant step”.
+. **Step keys must be stable** — changing a key after release makes resume
behaviour confusing for operators who already have history files.
+. **Prefer many small steps over one large step** — especially when a step can
take a long time (reindex, scroll, bulk update).
+
+`MigrationUtils.reIndex(...)` already registers its own sub-steps (clone,
recreate index, delete clone, refresh).
+You do not need to wrap those again unless you add work outside `reIndex`.
+
+==== Pitfall: do not nest `performMigrationStep` inside another step
+
+*This was the root cause of
https://issues.apache.org/jira/browse/UNOMI-943[UNOMI-943].*
+
+If step B is registered **inside** the closure of step A, then:
+
+* B is only evaluated while A runs.
+* When A is already marked `COMPLETED` in history, A's closure is never
entered again.
+* B is never registered and never runs — even if B never completed.
+
+Real impact: rollover **write aliases** for event/session indices were skipped
after a failed/resumed migration, so new writes failed silently.
+
+**Wrong** — alias configuration hidden inside `get-all-indices`:
+
+[source,groovy]
+----
+context.performMigrationStep("3.1.0-get-all-indices", () -> {
+ // ... reindex each index ...
+ context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> {
+ // configure aliases — NEVER REACHED on resume if outer step completed
+ })
+})
+----
+
+**Right** — each tracked step at script top level:
+
+[source,groovy]
+----
+context.performMigrationStep("3.1.0-get-all-indices", () -> {
+ // ... reindex each index ...
+})
+
+context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> {
+ // configure aliases — runs independently on resume
+})
+----
+
+==== Pitfall: make data transforms safe to re-run
+
+Step history prevents re-running a **whole** step, but partial failure inside
a step can still leave mixed state.
+Design Painless scripts and update queries so a second application does not
corrupt data.
+
+Good patterns:
+
+* **Check before mutate** — skip documents that already have the target shape
(tenant prefix on `_id`, `tenantId` set, legacy field already renamed).
+* **Copy-once fields** — only copy `nbOfVisits` → `totalNbOfVisits` when
`totalNbOfVisits` is null.
+* **Existence guards** — create an index or tenant document only when
`MigrationUtils.indexExists(...)` is false.
+* **Scoped queries** — limit `update_by_query` to documents that still match
the old state (wildcard on legacy `queryBuilder` IDs, etc.).
+
+Example guard (document ID already tenant-prefixed):
+
+[source,painless]
+----
+if (!ctx._id.startsWith(params.tenantId + '_') &&
!ctx._id.startsWith(params.systemTenantId + '_')) {
+ // transform ...
+}
+----
+
+==== Pitfall: bundled resources must exist and fail clearly
+
+Scripts load JSON and Painless from the bundle via:
+
+* `MigrationUtils.resourceAsString(bundleContext, "requestBody/...")`
+* `MigrationUtils.getFileWithoutComments(bundleContext,
"requestBody/.../script.painless")`
+
+Both throw `RuntimeException("Resource not found: …")` when the path is wrong.
+A missing file used to surface as an opaque `NullPointerException` in
`getFileWithoutComments` (fixed in UNOMI-943).
+
+Keep request bodies under
`tools/shell-commands/src/main/resources/requestBody/<version>/`.
+After adding a file, run unit tests or a dry migration in a staging cluster —
do not rely on compile-only checks.
+
+==== Configuration keys
+
+Prefer constants from `MigrationConfig` instead of raw string literals:
+
+[source,groovy]
+----
+import static org.apache.unomi.shell.migration.service.MigrationConfig.*
+
+String esAddress = context.getConfigString(CONFIG_ES_ADDRESS)
+String indexPrefix = context.getConfigString(INDEX_PREFIX)
+String tenantId = context.getConfigString(TENANT_ID)
+----
+
+Legacy scripts may still use `"esAddress"` and `"indexPrefix"`; new scripts
should use the constants.
+
+Optional settings and prompts are documented in
`org.apache.unomi.migration.cfg` (see shell command help for `unomi:migrate`).
+
+==== Destructive operations
+
+Reindex and index deletion are intentional but risky:
+
+* `MigrationUtils.reIndex` clones the source index, deletes the original,
recreates mappings, then copies data back.
+* Always confirm the index name is not a `-cloned` suffix (the helper rejects
reindexing clones).
+* For rollover indices, configure **aliases in a separate top-level step**
after all reindex work finishes.
+
+Log progress with `context.printMessage(...)` so operators can correlate Karaf
console output with `history.json`.
+
+==== Testing
+
+[cols="1,1,2", options="header"]
+|===
+| Test | Module | What it validates
+
+| `MigrationUtilsTest`
+| `tools/shell-commands`
+| Painless comment stripping, missing resource errors
+
+| `MigrationIT`
+| `itests`
+| Step history recovery (`checkMigrationRecoverySystem`); nested-step pitfall
guard (`checkNestedMigrationStepPitfall`, UNOMI-943)
+
+| `Migrate16xToCurrentVersionIT`
+| `itests`
+| Full chain from 1.6.x snapshot through all bundled scripts (Elasticsearch
only)
+|===
+
+When you add or change a 3.x script, extend `Migrate16xToCurrentVersionIT` if
the change affects observable data (tenant IDs, aliases, queryBuilder IDs,
profile fields, etc.).
+
+Run locally before opening a PR:
+
+[source,bash]
+----
+mvn -pl tools/shell-commands -am test -Dtest=MigrationUtilsTest
+----
+
+Full migration ITs run in CI on the integration matrix (~2 h); they are
required for script changes that affect persisted data shape.
+
+==== Pre-merge checklist
+
+Use this before submitting a migration PR:
+
+* [ ] File name matches `migrate-<version>-<priority>-<name>.groovy`
+* [ ] Every resumable unit uses its own **top-level** `performMigrationStep`
(no nesting)
+* [ ] Step keys are stable and describe one outcome
+* [ ] Painless / update logic is safe if applied twice inside a step
+* [ ] New `requestBody/` assets are committed and referenced with correct paths
+* [ ] `indexExists` / query filters guard create-only work
+* [ ] Unit or IT coverage updated where behaviour is testable
+* [ ] Version-specific operator doc updated if the upgrade path changes
(`migrate-*-to-*.adoc`)
diff --git a/manual/src/main/asciidoc/shell-commands.adoc
b/manual/src/main/asciidoc/shell-commands.adoc
index 94dd773b8..b3c0e3946 100644
--- a/manual/src/main/asciidoc/shell-commands.adoc
+++ b/manual/src/main/asciidoc/shell-commands.adoc
@@ -72,7 +72,7 @@ using the specified distribution feature name
(unomi-distribution-elasticsearch
|migrate
|fromVersion
-|This command must be used only when the Apache Unomi application is NOT
STARTED. It will perform migration of the data stored in search engine using
the argument fromVersion as a starting point.
+|This command must be used only when the Apache Unomi application is NOT
STARTED. It will perform migration of the data stored in search engine using
the argument fromVersion as a starting point. Contributors writing new scripts
should read <<_writing_migration_scripts,Writing migration scripts>>.
|stop
|n/a
diff --git
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
index 1bc480410..072d8ba9f 100644
---
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
+++
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
@@ -62,11 +62,16 @@ public class MigrationUtils {
HttpUtils.executePostRequest(httpClient, url, jsonData, null);
}
- public static String resourceAsString(BundleContext bundleContext, final
String resource) {
+ private static URL requireResource(BundleContext bundleContext, final
String resource) {
final URL url = bundleContext.getBundle().getResource(resource);
if (url == null) {
throw new RuntimeException("Resource not found: " + resource);
}
+ return url;
+ }
+
+ public static String resourceAsString(BundleContext bundleContext, final
String resource) {
+ final URL url = requireResource(bundleContext, resource);
try (InputStream stream = url.openStream()) {
return IOUtils.toString(stream, StandardCharsets.UTF_8);
} catch (final Exception e) {
@@ -75,7 +80,7 @@ public class MigrationUtils {
}
public static String getFileWithoutComments(BundleContext bundleContext,
final String resource) {
- final URL url = bundleContext.getBundle().getResource(resource);
+ final URL url = requireResource(bundleContext, resource);
try {
// Read the entire file into a string to preserve exact line
endings
String fileContent;
diff --git
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy
index 8a05cd1c6..a49916d83 100644
---
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy
+++
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-01-tenantDocumentIds.groovy
@@ -146,34 +146,35 @@ context.performMigrationStep("3.1.0-get-all-indices", ()
-> {
// Execute reindex
MigrationUtils.reIndex(context.getHttpClient(), bundleContext,
esAddress, indexName, newIndexSettings, updateScript, params, context,
"3.1.0-${indexName}-update")
}
-
- // Configure aliases for rollover indices after all reindexing is complete
- // For each rollover alias, find all indices and set the latest one as
write index
- context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> {
- String configureAliasBody =
MigrationUtils.resourceAsString(bundleContext,
"requestBody/2.2.0/configure_alias_body.json")
-
- // Process each rollover item type
- indexConfigs.each { itemType, config ->
- if (config.useRollover) {
- String alias = config.alias(indexPrefix)
- // Find all indices that match the rollover pattern (e.g.,
context-session-000001, context-session-000002)
- Set<String> rolloverIndices =
MigrationUtils.getIndexesPrefixedBy(context.getHttpClient(), esAddress,
"${indexPrefix}-${itemType}-")
-
- if (!rolloverIndices.isEmpty()) {
- // Sort indices to find the latest one (highest number)
- SortedSet<String> sortedIndices = new
TreeSet<>(rolloverIndices)
- String writeIndex = sortedIndices.last()
-
- // All indices except the last one should be read-only
- SortedSet<String> readIndices =
Collections.emptySortedSet()
- if (sortedIndices.size() > 1) {
- readIndices =
sortedIndices.headSet(sortedIndices.last())
- }
-
- context.printMessage("Configuring alias ${alias}: write
index=${writeIndex}, read indices=${readIndices}")
- MigrationUtils.configureAlias(context.getHttpClient(),
esAddress, alias, writeIndex, readIndices, configureAliasBody, context)
+})
+
+// Configure aliases for rollover indices after all reindexing is complete.
+// Top-level step so resume after failure can run alias configuration even when
+// "3.1.0-get-all-indices" is already marked COMPLETED (UNOMI-943).
+context.performMigrationStep("3.1.0-configure-rollover-aliases", () -> {
+ String configureAliasBody = MigrationUtils.resourceAsString(bundleContext,
"requestBody/2.2.0/configure_alias_body.json")
+
+ // Process each rollover item type
+ indexConfigs.each { itemType, config ->
+ if (config.useRollover) {
+ String alias = config.alias(indexPrefix)
+ // Find all indices that match the rollover pattern (e.g.,
context-session-000001, context-session-000002)
+ Set<String> rolloverIndices =
MigrationUtils.getIndexesPrefixedBy(context.getHttpClient(), esAddress,
"${indexPrefix}-${itemType}-")
+
+ if (!rolloverIndices.isEmpty()) {
+ // Sort indices to find the latest one (highest number)
+ SortedSet<String> sortedIndices = new
TreeSet<>(rolloverIndices)
+ String writeIndex = sortedIndices.last()
+
+ // All indices except the last one should be read-only
+ SortedSet<String> readIndices = Collections.emptySortedSet()
+ if (sortedIndices.size() > 1) {
+ readIndices = sortedIndices.headSet(sortedIndices.last())
}
+
+ context.printMessage("Configuring alias ${alias}: write
index=${writeIndex}, read indices=${readIndices}")
+ MigrationUtils.configureAlias(context.getHttpClient(),
esAddress, alias, writeIndex, readIndices, configureAliasBody, context)
}
}
- })
+ }
})
diff --git
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
index aa1c0cb9d..96129f47c 100644
---
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
+++
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
@@ -27,6 +27,7 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.*;
public class MigrationUtilsTest {
@@ -199,4 +200,13 @@ public class MigrationUtilsTest {
String expected = "code1 code2 code3";
testCommentHandling(input, expected);
}
+
+ @Test
+ public void testGetFileWithoutCommentsMissingResource() {
+ when(bundle.getResource("missing.painless")).thenReturn(null);
+
+ RuntimeException ex = assertThrows(RuntimeException.class,
+ () -> MigrationUtils.getFileWithoutComments(bundleContext,
"missing.painless"));
+ assertEquals("Resource not found: missing.painless", ex.getMessage());
+ }
}
\ No newline at end of file