Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/*
* Copyright 2026 Collate.
* 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.
*/

package org.openmetadata.it.tests;

import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fasterxml.jackson.core.type.TypeReference;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openmetadata.it.factories.DatabaseSchemaTestFactory;
import org.openmetadata.it.factories.DatabaseServiceTestFactory;
import org.openmetadata.it.factories.TableTestFactory;
import org.openmetadata.it.factories.UserTestFactory;
import org.openmetadata.it.util.SdkClients;
import org.openmetadata.it.util.TestNamespace;
import org.openmetadata.it.util.TestNamespaceExtension;
import org.openmetadata.schema.api.tests.CreateTestCaseResult;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.entity.services.DatabaseService;
import org.openmetadata.schema.entity.tasks.Task;
import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.tests.TestCase;
import org.openmetadata.schema.tests.type.Assigned;
import org.openmetadata.schema.tests.type.TestCaseResolutionStatus;
import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes;
import org.openmetadata.schema.tests.type.TestCaseStatus;
import org.openmetadata.schema.type.TaskEntityStatus;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.sdk.client.OpenMetadataClient;
import org.openmetadata.sdk.fluent.builders.TestCaseBuilder;
import org.openmetadata.sdk.models.ListParams;
import org.openmetadata.sdk.models.ListResponse;
import org.openmetadata.sdk.network.HttpMethod;
import org.openmetadata.sdk.network.RequestOptions;

/**
* E2E tests for automatic incident assignment (issue #22967).
*
* <p>When a test case fails and the incident inherits assignees from the target entity's owners,
* the incident must land on the workflow's {@code assigned} stage rather than sitting in {@code
* new} with an assignee no consumer of the TCRS time series can see.
*/
@Execution(ExecutionMode.SAME_THREAD)
@ExtendWith(TestNamespaceExtension.class)
public class AutoAssignIncidentIT {

private static final String WORKFLOW_NAME = "TestCaseResolutionTaskWorkflow";
private static final String NEW_STAGE_ID = "new";
private static final String ASSIGNED_STAGE_ID = "assigned";
private static final Duration PIPELINE_TIMEOUT = Duration.ofSeconds(120);

@Test
void ownedTestCase_failedResult_incidentAutoAssigned(TestNamespace ns) {
OpenMetadataClient client = SdkClients.adminClient();
String id = ns.shortPrefix();

User owner = UserTestFactory.createUser(ns, "own" + id);
TestCase testCase = createFailingTestCaseOwnedBy(client, ns, id, owner);

Task task = awaitIncidentTask(client, testCase);
assertEquals(
ASSIGNED_STAGE_ID,
task.getWorkflowStageId(),
"Incident with inherited owners should advance past the New stage");
assertEquals(TaskEntityStatus.InProgress, task.getStatus());
assertTrue(
task.getAssignees().stream().anyMatch(a -> owner.getId().equals(a.getId())),
"Owner should be an assignee of the incident task");

TestCase failedTc =
client.testCases().getByName(testCase.getFullyQualifiedName(), "incidentId");
UUID stateId = failedTc.getIncidentId();
assertNotNull(stateId);

await()
.atMost(PIPELINE_TIMEOUT)
.pollInterval(Duration.ofSeconds(2))
.until(
() -> findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned) != null);

TestCaseResolutionStatus assigned =
findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned);
Assigned details =
JsonUtils.convertValue(assigned.getTestCaseResolutionStatusDetails(), Assigned.class);
assertNotNull(details.getAssignee(), "Assigned TCRS record must carry an assignee");
assertEquals(
owner.getId(),
details.getAssignee().getId(),
"Assigned TCRS record should name the owner as assignee");
}

@Test
void unownedTestCase_failedResult_incidentStaysNew(TestNamespace ns) {
OpenMetadataClient client = SdkClients.adminClient();
String id = ns.shortPrefix();

TestCase testCase = createFailingTestCaseOwnedBy(client, ns, id, null);

Task task = awaitIncidentTask(client, testCase);
assertEquals(
NEW_STAGE_ID,
task.getWorkflowStageId(),
"Incident without owners has nobody to assign to and must stay New");
assertEquals(TaskEntityStatus.Open, task.getStatus());

TestCase failedTc =
client.testCases().getByName(testCase.getFullyQualifiedName(), "incidentId");
UUID stateId = failedTc.getIncidentId();
assertNotNull(stateId);

await()
.atMost(PIPELINE_TIMEOUT)
.pollInterval(Duration.ofSeconds(2))
.until(() -> findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.New) != null);
assertNull(
findTcrsOfType(client, stateId, TestCaseResolutionStatusTypes.Assigned),
"An unowned incident must not produce an Assigned record");
}

private TestCase createFailingTestCaseOwnedBy(
OpenMetadataClient client, TestNamespace ns, String id, User owner) {
DatabaseService service = DatabaseServiceTestFactory.createPostgresWithName("sv" + id, ns);
DatabaseSchema schema = DatabaseSchemaTestFactory.createSimpleWithName("sc" + id, ns, service);
Table table =
TableTestFactory.createSimpleWithName("tbl" + id, ns, schema.getFullyQualifiedName());

if (owner != null) {
setTableOwner(client, table, owner);
}

TestCase testCase =
TestCaseBuilder.create(client)
.name("tc" + id)
.forTable(table)
.testDefinition("tableRowCountToEqual")
.parameter("value", "100")
.create();

awaitWorkflowDeployed(client);
createFailedTestResult(client, testCase);
return testCase;
}

private void setTableOwner(OpenMetadataClient client, Table table, User owner) {
String patchJson =
String.format(
"[{\"op\": \"add\", \"path\": \"/owners\", \"value\": "
+ "[{\"id\": \"%s\", \"type\": \"user\"}]}]",
owner.getId());
client
.getHttpClient()
.executeForString(
HttpMethod.PATCH,
"/v1/tables/" + table.getId(),
patchJson,
RequestOptions.builder().header("Content-Type", "application/json-patch+json").build());
}

private void awaitWorkflowDeployed(OpenMetadataClient client) {
await()
.atMost(Duration.ofSeconds(30))
.pollInterval(Duration.ofSeconds(2))
.until(
() -> {
try {
var wd = client.workflowDefinitions().getByName(WORKFLOW_NAME, "deployed");
return Boolean.TRUE.equals(wd.getDeployed());
} catch (Exception e) {
return false;
}
});
}

private void createFailedTestResult(OpenMetadataClient client, TestCase testCase) {
CreateTestCaseResult result = new CreateTestCaseResult();
result.setTimestamp(System.currentTimeMillis());
result.setTestCaseStatus(TestCaseStatus.Failed);
result.setResult("Test failed");
client.testCaseResults().create(testCase.getFullyQualifiedName(), result);
}

private Task awaitIncidentTask(OpenMetadataClient client, TestCase testCase) {
AtomicReference<Task> taskRef = new AtomicReference<>();
await()
.atMost(PIPELINE_TIMEOUT)
.pollInterval(Duration.ofSeconds(2))
.until(
() -> {
Task found = findIncidentTaskForTestCase(client, testCase);
boolean started = found != null && found.getWorkflowInstanceId() != null;
if (started) {
taskRef.set(found);
}
return started;
});
return taskRef.get();
}

private Task findIncidentTaskForTestCase(OpenMetadataClient client, TestCase testCase) {
ListParams params =
new ListParams()
.addFilter("category", "Incident")
.setFields("assignees,payload,about")
.setLimit(100);
ListResponse<Task> tasks = client.tasks().list(params);

return tasks.getData().stream()
.filter(
task ->
task.getAbout() != null
&& testCase
.getFullyQualifiedName()
.equals(task.getAbout().getFullyQualifiedName()))
.findFirst()
.orElse(null);
}

private TestCaseResolutionStatus findTcrsOfType(
OpenMetadataClient client, UUID stateId, TestCaseResolutionStatusTypes type) {
return listTcrsForStateId(client, stateId).stream()
.filter(r -> r.getTestCaseResolutionStatusType() == type)
.findFirst()
.orElse(null);
}

@SuppressWarnings("unchecked")
private List<TestCaseResolutionStatus> listTcrsForStateId(
OpenMetadataClient client, UUID stateId) {
try {
String response =
client
.getHttpClient()
.executeForString(
HttpMethod.GET,
"/v1/dataQuality/testCases/testCaseIncidentStatus/stateId/" + stateId,
null,
RequestOptions.builder().build());

Map<String, Object> result = JsonUtils.readValue(response, new TypeReference<>() {});
List<Object> data = (List<Object>) result.get("data");
if (data == null) {
return List.of();
}
return data.stream()
.map(d -> JsonUtils.convertValue(d, TestCaseResolutionStatus.class))
.toList();
} catch (Exception e) {
return List.of();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.jdbi3.TestCaseRepository;
import org.openmetadata.service.jdbi3.TestCaseResolutionStatusRepository;
import org.openmetadata.service.tasks.IncidentWorkflowStages;

/**
* Mirrors task-first incident lifecycle events into the legacy {@code
Expand Down Expand Up @@ -80,10 +81,10 @@ public final class IncidentTcrsSyncHandler {

private static final Map<String, TestCaseResolutionStatusTypes> STAGE_TO_TCRS_STATUS =
Map.of(
"new", TestCaseResolutionStatusTypes.New,
"ack", TestCaseResolutionStatusTypes.Ack,
"assigned", TestCaseResolutionStatusTypes.Assigned,
"resolved", TestCaseResolutionStatusTypes.Resolved);
IncidentWorkflowStages.NEW_STAGE_ID, TestCaseResolutionStatusTypes.New,
IncidentWorkflowStages.ACK_STAGE_ID, TestCaseResolutionStatusTypes.Ack,
IncidentWorkflowStages.ASSIGNED_STAGE_ID, TestCaseResolutionStatusTypes.Assigned,
IncidentWorkflowStages.RESOLVED_STAGE_ID, TestCaseResolutionStatusTypes.Resolved);

private static final String TEST_CASE_TYPE = "testCase";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
import org.openmetadata.service.resources.dqtests.TestCaseResolutionStatusResource;
import org.openmetadata.service.resources.feeds.MessageParser;
import org.openmetadata.service.search.SearchListFilter;
import org.openmetadata.service.tasks.IncidentWorkflowStages;
import org.openmetadata.service.tasks.TaskWorkflowLifecycleResolver;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.RestUtil;
import org.openmetadata.service.util.incidentSeverityClassifier.IncidentSeverityClassifierInterface;
Expand Down Expand Up @@ -568,9 +570,59 @@ private static UUID createIncidentTask(TestCase testCase, String updatedBy) {
"Incident task created on test failure: id={}, testCase={}",
task.getId(),
fullTestCase.getFullyQualifiedName());
advanceAutoAssignedIncident(taskRepository, task.getId(), assignees, updatedBy);
return task.getId();
}

/**
* Moves an incident that was auto-assigned from the test case owners out of the workflow's {@code
* new} stage and into {@code assigned}, by driving the same {@code assign} transition a manual
* assignment uses.
*
* <p>Without this the incident stays in {@code New}, and because the TCRS mirror only carries
* assignee details on {@code Assigned} records, the Incident Manager renders it as unassigned even
* though the task itself has assignees.
*/
private static void advanceAutoAssignedIncident(
TaskRepository taskRepository,
UUID taskId,
List<EntityReference> assignees,
String updatedBy) {
if (!nullOrEmpty(assignees)) {
try {
Task current = taskRepository.get(null, taskId, taskRepository.getFields("*"));
if (canAdvanceToAssignedStage(current)) {
taskRepository.resolveTaskWithWorkflow(
current,
IncidentWorkflowStages.ASSIGN_TRANSITION_ID,
null,
null,
null,
Comment thread
gitar-bot[bot] marked this conversation as resolved.
null,
updatedBy);
LOG.info("Incident task {} auto-advanced to the assigned stage", taskId);
} else {
LOG.warn(
"Incident task {} has assignees but sits at stage '{}' instead of '{}'; it stays New and renders as unassigned",
taskId,
current.getWorkflowStageId(),
IncidentWorkflowStages.NEW_STAGE_ID);
}
} catch (Exception e) {
// Best effort: an incident that fails to advance is still a usable incident sitting in
// New, so never fail test result ingestion over it.
LOG.warn("Failed to auto-advance incident task {} to the assigned stage", taskId, e);
}
}
}

private static boolean canAdvanceToAssignedStage(Task task) {
return IncidentWorkflowStages.NEW_STAGE_ID.equals(task.getWorkflowStageId())
&& TaskWorkflowLifecycleResolver.findTransition(
task, IncidentWorkflowStages.ASSIGN_TRANSITION_ID)
!= null;
}

private void setResolutionMetrics(
TestCaseResolutionStatus lastIncident, TestCaseResolutionStatus newIncident) {
List<Metric> metrics = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Collate
* 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.
*/

package org.openmetadata.service.tasks;

/**
* Stage and transition identifiers declared by the {@code TestCaseResolutionTaskWorkflow} seed
* definition. Kept in one place so the code paths that mirror or drive that workflow —
* {@code IncidentTcrsSyncHandler} and the incident auto-assignment path — cannot drift apart.
*/
public final class IncidentWorkflowStages {
public static final String NEW_STAGE_ID = "new";
public static final String ACK_STAGE_ID = "ack";
public static final String ASSIGNED_STAGE_ID = "assigned";
public static final String RESOLVED_STAGE_ID = "resolved";

public static final String ASSIGN_TRANSITION_ID = "assign";

private IncidentWorkflowStages() {}
}
Loading