diff --git a/project-properties.gradle b/project-properties.gradle index 47a56b3507..f19eaafdbb 100755 --- a/project-properties.gradle +++ b/project-properties.gradle @@ -16,7 +16,7 @@ project.ext { isDebugMode = System.getProperty("DEBUG", "false") == "true" releaseMode = project.hasProperty("releaseMode") ? project.releaseMode.toBoolean() : false scriptsUrl = commonScriptsUrl + (releaseMode ? '5.14.0' : 'develop') - migrationsUrl = migrationsScriptsUrl + (releaseMode ? 'rc/6.0.0' : 'develop') + migrationsUrl = migrationsScriptsUrl + (releaseMode ? 'rc/6.0.0' : 'feature/pipelines') manifestSchemaUrl = schemaUrl + ('manifest.schema.json') //TODO refactor with archive download testScriptsSrc = [ @@ -115,7 +115,9 @@ project.ext { (migrationsUrl + '/migrations/225_extract_launch_attribute.up.sql') : 'V225__extract_launch_attribute.up.sql', (migrationsUrl + '/migrations/226_extract_launch_statistics.up.sql') : 'V226__extract_launch_statistics.up.sql', (migrationsUrl + '/migrations/227_update_merge_launch_after_launch_tables_split.up.sql') : 'V227__update_merge_launch_after_launch_tables_split.up.sql', - (migrationsUrl + '/migrations/228_manual_launch_perf_indexes.up.sql') : 'V228__manual_launch_perf_indexes.up.sql' + (migrationsUrl + '/migrations/228_add_automation_integration_group.up.sql') : 'V228__add_automation_integration_group.up.sql', + (migrationsUrl + '/migrations/229_manual_launch_perf_indexes.up.sql') : 'V229__manual_launch_perf_indexes.up.sql', + (migrationsUrl + '/migrations/230_pipeline_and_tms_quality.up.sql') : 'V230__pipeline_and_tms_quality.up.sql' ] excludeTests = ['**/entity/**', '**/aop/**', diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/AbstractCiTriggerConnector.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/AbstractCiTriggerConnector.java new file mode 100644 index 0000000000..c6e583bef7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/AbstractCiTriggerConnector.java @@ -0,0 +1,49 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; + +/** + * Shared integration-parameter lookup for {@link CiTriggerConnector} implementations. + */ +abstract class AbstractCiTriggerConnector implements CiTriggerConnector { + + protected String requiredParam(Integration integration, String paramName) { + var value = stringParam(integration, paramName); + if (value == null || value.isBlank()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Missing required integration parameter: " + paramName); + } + return value; + } + + protected String stringParam(Integration integration, String paramName) { + if (integration == null || integration.getParams() == null || integration.getParams().getParams() == null) { + return null; + } + var value = integration.getParams().getParams().get(paramName); + return value instanceof String stringValue ? stringValue : null; + } + + /** + * What the stage's execution reported for the given parameter, or null. + */ + protected String retryParameter(PipelineStage stage, String name) { + var execution = execution(stage); + return execution == null || execution.getRetryParameters() == null ? null : execution.getRetryParameters().get(name); + } + + /** + * The identifier of the stage's run in the system that ran it, or null. + */ + protected String externalRunId(PipelineStage stage) { + var execution = execution(stage); + return execution == null ? null : execution.getExternalRunId(); + } + + private PipelineExecution execution(PipelineStage stage) { + return stage.getExecution() == null ? null : stage.getExecution().getContent(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiHttpClient.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiHttpClient.java new file mode 100644 index 0000000000..6c3fe100e2 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiHttpClient.java @@ -0,0 +1,103 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.ParseException; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.util.TimeValue; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.stereotype.Component; + +/** + * Minimal HTTPS-only POST client shared by the CI trigger connectors. + */ +@Component +public class CiHttpClient implements DisposableBean { + + private static final Logger LOGGER = LoggerFactory.getLogger(CiHttpClient.class); + + private final CloseableHttpClient httpClient; + + public CiHttpClient() { + var requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofSeconds(10)) + .setResponseTimeout(Timeout.ofSeconds(30)) + .build(); + + var connectionManager = PoolingHttpClientConnectionManagerBuilder.create() + .setMaxConnTotal(50) + .setMaxConnPerRoute(10) + .build(); + + this.httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .setRetryStrategy(new DefaultHttpRequestRetryStrategy(2, TimeValue.ofSeconds(1L))) + .disableRedirectHandling() + .build(); + } + + /** + * Executes an HTTPS POST with a JSON body and returns the response body + * (empty string if there is none), so callers that need it (e.g. to read a + * created resource's URL out of the response) don't have to re-request it. + * + * @param url absolute HTTPS URL + * @param headers request headers (e.g. Authorization) + * @param jsonBody request body, already serialized as JSON + */ + public String postForStatus(String url, Map headers, String jsonBody) { + validateHttpsUrl(url); + var request = new HttpPost(url); + headers.forEach(request::setHeader); + request.setHeader("Accept", "application/json"); + request.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(request)) { + String body = response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity()); + if (response.getCode() >= 400) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "CI trigger request failed. HTTP code: " + response.getCode() + ". " + body); + } + return body; + } catch (IOException | ParseException e) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Failed to execute CI trigger request: " + e.getMessage()); + } + } + + private void validateHttpsUrl(String url) { + try { + URI uri = new URI(url); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null) { + throw new URISyntaxException(url, "URL must use HTTPS and include a host"); + } + } catch (URISyntaxException e) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "CI trigger URL must be a valid HTTPS URL"); + } + } + + @Override + public void destroy() { + try { + httpClient.close(); + } catch (IOException e) { + LOGGER.error("Failed to close CI HTTP client", e); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiTriggerConnector.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiTriggerConnector.java new file mode 100644 index 0000000000..5fd7dc7844 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiTriggerConnector.java @@ -0,0 +1,38 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; + +/** + * Runs a stage again in the CI system that ran it (the "Retry Upload" button). + *

+ * Example: the Upload stage was reported with {@code executorType: "GITHUB_ACTIONS"} and retry parameters + * {@code {repo: "org/repo", workflowRef: "ai-pipeline.yml"}}. The connector whose + * {@link #getSupportedExecutorType()} is {@code GITHUB_ACTIONS} reads those parameters, calls GitHub with the + * project's GitHub Actions integration (token) and returns the URL of the new run. + *

+ * This is the outbound direction ReportPortal needs for stage retry, the opposite of the inbound TMS sync + * connectors, which only ever pull data in. An execution of any other kind simply has no connector and is + * not retryable. + */ +public interface CiTriggerConnector { + + /** + * The executor type this connector handles, e.g. {@code GITHUB_ACTIONS}. + */ + String getSupportedExecutorType(); + + /** + * Name of the project integration type holding the credentials this connector needs. + */ + String getIntegrationTypeName(); + + /** + * Triggers a rerun of the job the stage's execution describes. + * + * @param integration credentials/config for the system (token, base URL) + * @param stage the stage to retry; its execution identifies the job + * @return the URL of the newly triggered run + */ + String triggerStageRerun(Integration integration, PipelineStage stage); +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnector.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnector.java new file mode 100644 index 0000000000..6b91cd7b02 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnector.java @@ -0,0 +1,81 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriUtils; + +/** + * Re-triggers a stage's agent job via the GitHub Actions + * + * workflow dispatch API. The retry parameter {@code repo} is {@code "owner/repo"}, + * {@code workflowRef} is the workflow file name (e.g. {@code ai-pipeline.yml}). + */ +@Component +@RequiredArgsConstructor +public class GitHubActionsCiConnector extends AbstractCiTriggerConnector { + + private static final String TOKEN_PARAM = "token"; + private static final String GIT_REF_PARAM = "ref"; + private static final String REPO_PARAM = "repo"; + private static final String DEFAULT_GIT_REF = "main"; + private static final String WORKFLOW_PARAM = "workflowRef"; + + private final CiHttpClient ciHttpClient; + private final ObjectMapper objectMapper; + + @Override + public String getSupportedExecutorType() { + return "GITHUB_ACTIONS"; + } + + @Override + public String getIntegrationTypeName() { + return "github-actions"; + } + + @Override + public String triggerStageRerun(Integration integration, PipelineStage stage) { + var repo = retryParameter(stage, REPO_PARAM); + var workflowRef = retryParameter(stage, WORKFLOW_PARAM); + if (repo == null || workflowRef == null) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Stage is missing its CI repo/workflow reference"); + } + var token = requiredParam(integration, TOKEN_PARAM); + // The stage's repo is client-supplied at ingest time and must never steer which repo the + // integration's token is used against - it's only accepted here once it's confirmed to be + // the exact repo this integration was configured for. + var configuredRepo = requiredParam(integration, REPO_PARAM); + if (!configuredRepo.equals(repo)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Stage CI repo '" + repo + "' does not match the repo configured for this project's GitHub Actions integration"); + } + var gitRef = stringParam(integration, GIT_REF_PARAM); + + var workflowFile = UriUtils.encodePathSegment(workflowRef, StandardCharsets.UTF_8); + var url = "https://api.github.com/repos/" + repo + "/actions/workflows/" + workflowFile + "/dispatches"; + + ObjectNode body = objectMapper.createObjectNode(); + body.put("ref", gitRef == null || gitRef.isBlank() ? DEFAULT_GIT_REF : gitRef); + ObjectNode inputs = body.putObject("inputs"); + inputs.put("pipeline_stage_id", String.valueOf(stage.getId())); + if (externalRunId(stage) != null) { + inputs.put("rerun_job_id", externalRunId(stage)); + } + + ciHttpClient.postForStatus(url, Map.of( + "Authorization", "Bearer " + token, + "Accept", "application/vnd.github+json", + "X-GitHub-Api-Version", "2022-11-28" + ), body.toString()); + + return "https://github.com/" + repo + "/actions/workflows/" + workflowFile; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnector.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnector.java new file mode 100644 index 0000000000..a8d509b4f4 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnector.java @@ -0,0 +1,95 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriUtils; + +/** + * Re-triggers a stage's agent job via the GitLab + * pipeline trigger API. + * the retry parameter {@code repo} holds the GitLab project id, {@code workflowRef} + * the git ref (branch/tag) the pipeline should run on. + */ +@Component +@RequiredArgsConstructor +public class GitLabCiConnector extends AbstractCiTriggerConnector { + + private static final String BASE_URL_PARAM = "url"; + private static final String TOKEN_PARAM = "triggerToken"; + private static final String PROJECT_ID_PARAM = "projectId"; + private static final String DEFAULT_BASE_URL = "https://gitlab.com"; + private static final String REPO_PARAM = "repo"; + private static final String REF_PARAM = "workflowRef"; + + private final CiHttpClient ciHttpClient; + private final ObjectMapper objectMapper; + + @Override + public String getSupportedExecutorType() { + return "GITLAB_CI"; + } + + @Override + public String getIntegrationTypeName() { + return "gitlab-ci"; + } + + @Override + public String triggerStageRerun(Integration integration, PipelineStage stage) { + var repo = retryParameter(stage, REPO_PARAM); + var ref = retryParameter(stage, REF_PARAM); + if (repo == null || ref == null) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Stage is missing its CI project/ref reference"); + } + var token = requiredParam(integration, TOKEN_PARAM); + // The stage's project id is client-supplied at ingest time and must never steer which + // GitLab project the integration's trigger token is used against - it's only accepted here + // once it's confirmed to be the exact project this integration was configured for. + var configuredProjectId = requiredParam(integration, PROJECT_ID_PARAM); + if (!configuredProjectId.equals(repo)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Stage CI project '" + repo + "' does not match the project configured for this project's GitLab CI integration"); + } + var baseUrl = stringParam(integration, BASE_URL_PARAM); + var effectiveBaseUrl = baseUrl == null || baseUrl.isBlank() ? DEFAULT_BASE_URL : baseUrl; + + var projectId = UriUtils.encodePathSegment(repo, StandardCharsets.UTF_8); + var url = effectiveBaseUrl + "/api/v4/projects/" + projectId + "/trigger/pipeline"; + + ObjectNode body = objectMapper.createObjectNode(); + body.put("token", token); + body.put("ref", ref); + ObjectNode variables = body.putObject("variables"); + variables.put("PIPELINE_STAGE_ID", String.valueOf(stage.getId())); + if (externalRunId(stage) != null) { + variables.put("RERUN_JOB_ID", externalRunId(stage)); + } + + var responseBody = ciHttpClient.postForStatus(url, Collections.emptyMap(), body.toString()); + var fallbackUrl = effectiveBaseUrl + "/api/v4/projects/" + projectId + "/pipelines"; + return extractPipelineUrl(responseBody, fallbackUrl); + } + + /** + * GitLab's trigger-pipeline response body carries the created pipeline's own + * {@code web_url} — prefer that (a page a human can open) over the raw REST + * endpoint this connector called. + */ + private String extractPipelineUrl(String responseBody, String fallbackUrl) { + try { + var webUrl = objectMapper.readTree(responseBody).path("web_url").asText(null); + return webUrl != null && !webUrl.isBlank() ? webUrl : fallbackUrl; + } catch (JsonProcessingException | IllegalArgumentException e) { + return fallbackUrl; + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/controller/PipelineController.java b/src/main/java/com/epam/reportportal/base/core/aifactory/controller/PipelineController.java new file mode 100644 index 0000000000..f2126cfb6c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/controller/PipelineController.java @@ -0,0 +1,271 @@ +package com.epam.reportportal.base.core.aifactory.controller; + +import static com.epam.reportportal.base.auth.permissions.Permissions.ALLOWED_TO_EDIT_PROJECT; +import static com.epam.reportportal.base.auth.permissions.Permissions.ALLOWED_TO_VIEW_PROJECT; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationPatchRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineSettingsRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRS; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationFilter; +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationService; +import com.epam.reportportal.base.core.aifactory.service.PipelineService; +import com.epam.reportportal.base.core.aifactory.service.PipelineStageRetryService; +import com.epam.reportportal.base.infrastructure.persistence.commons.EntityUtils; +import com.epam.reportportal.base.infrastructure.persistence.commons.ReportPortalUser; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.epam.reportportal.base.model.Page; +import com.epam.reportportal.base.util.OffsetRequest; +import com.epam.reportportal.base.util.ProjectExtractor; +import com.epam.reportportal.base.ws.converter.PagedResourcesAssembler; +import com.epam.reportportal.base.ws.resolver.PagingOffset; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Pipeline domain API: read access to pipeline definitions and their iterations, + * the reporting endpoints called by CI/the agent (an iteration, then its stages as they finish) + * and the per-stage CI retry trigger. + */ +@RestController +@RequestMapping("/v1/project/{projectKey}/pipeline") +@Tag(name = "Pipeline", description = "Generic pipelines: iterations made of stages that report anything as their result") +@RequiredArgsConstructor +public class PipelineController { + + private final PipelineService pipelineService; + private final PipelineIterationService pipelineIterationService; + private final PipelineStageRetryService pipelineStageRetryService; + private final ProjectExtractor projectExtractor; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @Operation(summary = "List pipeline definitions for a project", + description = "Ordered by name, then id. Response is a page: {content, page}. With search, only pipelines whose " + + "name matches, or that have an iteration matching, are returned.") + @Parameter(name = "search", in = ParameterIn.QUERY, description = "Case-insensitive substring over the pipeline " + + "name and the values of its iterations' attributes; or an exact iteration number") + @Parameter(name = "offset", in = ParameterIn.QUERY, description = "Number of items to skip (default 0)") + @Parameter(name = "limit", in = ParameterIn.QUERY, description = "Page size (default 100, max 200)") + public Page getPipelines( + @PathVariable String projectKey, + @RequestParam(required = false) String search, + @PagingOffset OffsetRequest offsetRequest, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + var page = pipelineService.getPipelines(projectId, search, offsetRequest); + return PagedResourcesAssembler.pageConverter().apply(page); + } + + @PatchMapping("/{pipelineId}") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Change the settings of a pipeline definition") + public PipelineRS patchPipelineSettings( + @PathVariable String projectKey, + @PathVariable Long pipelineId, + @Valid @RequestBody PipelineSettingsRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineService.patchPipelineSettings(projectId, pipelineId, rq); + } + + @GetMapping("/{pipelineId}/iteration") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @Operation(summary = "List iterations of a pipeline definition", + description = "Newest first (by iterationNumber). Response is a page: {content, page}.") + @Parameter(name = "search", in = ParameterIn.QUERY, description = "Case-insensitive substring over the values of " + + "the iteration's attributes, or exact iteration number (a leading # is ignored)") + @Parameter(name = "status", in = ParameterIn.QUERY, description = "Only iterations whose status category is one " + + "of these (repeat the parameter or comma-separate)") + @Parameter(name = "attribute", in = ParameterIn.QUERY, description = "Exact attribute filter as key:value, e.g. " + + "requirement:US-TMS-EXP-002 (repeat the parameter to require several)") + @Parameter(name = "offset", in = ParameterIn.QUERY, description = "Number of items to skip (default 0)") + @Parameter(name = "limit", in = ParameterIn.QUERY, description = "Page size (default 100, max 200)") + public Page getPipelineIterations( + @PathVariable String projectKey, + @PathVariable Long pipelineId, + @RequestParam(required = false) String search, + @RequestParam(required = false) Set status, + @RequestParam(required = false) List attribute, + @PagingOffset OffsetRequest offsetRequest, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + var page = pipelineIterationService.getPipelineIterations(projectId, pipelineId, + new PipelineIterationFilter(search, status, parseAttributeFilters(attribute)), offsetRequest); + return PagedResourcesAssembler.pageConverter().apply(page); + } + + @PostMapping("/iteration") + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Create a pipeline iteration (called by CI/the agent)", + description = "Reporting endpoint; requires project edit permission. The pipeline definition is created on the " + + "first report that names a pipeline (unique per project) and records its type. The iteration may " + + "carry the stages known so far; further stages are reported one by one with PUT " + + ".../iteration/{iterationId}/stage/{stageKey}. With rerunOfIterationId the new iteration is marked as " + + "a re-run of that iteration. Test case display ids that do not resolve are skipped and returned in " + + "warnings. Send an Idempotency-Key header to make retries safe: the same key with the same body " + + "returns the iteration created the first time (200, Idempotent-Replayed: true); the same key with a " + + "different body is rejected with 409. Keys are scoped to the pipeline and limited to 128 characters.") + @ApiResponse(responseCode = "201", description = "Iteration created") + @ApiResponse(responseCode = "200", description = "Replay of an earlier request with the same Idempotency-Key") + @ApiResponse(responseCode = "409", description = "Idempotency-Key already used with a different request body") + public ResponseEntity createPipelineIteration( + @PathVariable String projectKey, + @Valid @RequestBody PipelineIterationRQ rq, + @Parameter(description = "Makes the report idempotent per pipeline; 1-128 characters") + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + var result = pipelineIterationService.createPipelineIteration(projectId, user.getUserId(), rq, idempotencyKey); + if (result.replayed()) { + return ResponseEntity.ok().header("Idempotent-Replayed", "true").body(result.iteration()); + } + return ResponseEntity.status(HttpStatus.CREATED).body(result.iteration()); + } + + @PutMapping("/iteration/{iterationId}/stage/{stageKey}") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Report one stage of an iteration (called by CI/the agent)", + description = "Creates the stage or updates it in place, so a job can report it as pending, running and " + + "finished. Reporting is idempotent per (iteration, stageKey): the same job reporting again updates " + + "the current attempt, a finished stage reported by a different CI job starts a new attempt and the " + + "finished one is kept. The iteration status is recalculated and the updated iteration returned.") + @ApiResponse(responseCode = "404", description = "Iteration not found") + @ApiResponse(responseCode = "400", description = "Invalid report, or the stage is maintained by ReportPortal") + public PipelineIterationDetailRS reportPipelineStage( + @PathVariable String projectKey, + @PathVariable Long iterationId, + @PathVariable String stageKey, + @Valid @RequestBody PipelineStageRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineIterationService.reportStage(projectId, iterationId, stageKey, rq); + } + + @PatchMapping("/iteration/{iterationId}") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Update an iteration's attributes and timing (called by CI/the agent)", + description = "Adds or replaces attributes by key, removes attributes by key, sets start/finish time. " + + "The iteration status is never set directly - it follows from the stages.") + public PipelineIterationDetailRS patchPipelineIteration( + @PathVariable String projectKey, + @PathVariable Long iterationId, + @Valid @RequestBody PipelineIterationPatchRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineIterationService.patchPipelineIteration(projectId, iterationId, rq); + } + + @GetMapping("/iteration/{iterationId}") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @Operation(summary = "Get pipeline iteration detail with its stages") + public PipelineIterationDetailRS getPipelineIteration( + @PathVariable String projectKey, + @PathVariable Long iterationId, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineIterationService.getPipelineIteration(projectId, iterationId); + } + + @GetMapping("/iteration/{iterationId}/compare") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @Operation(summary = "Compare two iterations of the same pipeline definition", + description = "The iteration in the path is the candidate, the one given by 'with' is the baseline. Stages " + + "are matched by name, metrics are the numeric attributes, and when both iterations cover the same " + + "scope (equal comparison-key attributes) result rows are joined side by side.") + @Parameter(name = "with", in = ParameterIn.QUERY, required = true, description = "Id of the baseline iteration") + public PipelineCompareRS comparePipelineIterations( + @PathVariable String projectKey, + @PathVariable Long iterationId, + @RequestParam("with") Long baselineIterationId, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineIterationService.comparePipelineIterations(projectId, iterationId, baselineIterationId); + } + + @PostMapping("/iteration/{iterationId}/stage/{stageId}/retry") + @ResponseStatus(HttpStatus.ACCEPTED) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Trigger a CI rerun for a single pipeline stage", + description = "Allowed only for a retryable stage that failed or waits for a human. The request body is optional.") + @ApiResponse(responseCode = "202", description = "Retry triggered; the stage and iteration become pending") + @ApiResponse(responseCode = "400", description = "Stage already passed, not retryable, or no CI integration configured") + @ApiResponse(responseCode = "404", description = "Stage not found") + @ApiResponse(responseCode = "409", description = "A retry or run is already in progress for this stage") + public PipelineStageRetryRS retryPipelineIterationStage( + @PathVariable String projectKey, + @PathVariable Long iterationId, + @PathVariable Long stageId, + @RequestBody(required = false) PipelineStageRetryRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineStageRetryService.retryPipelineIterationStage(projectId, user.getUserId(), iterationId, stageId, rq); + } + + /** + * Parses repeated {@code key:value} filters; the first colon separates key from value. + */ + private static LinkedHashMap parseAttributeFilters(List filters) { + var result = new LinkedHashMap(); + if (filters == null) { + return result; + } + for (var filter : filters) { + int separator = filter.indexOf(':'); + if (separator <= 0) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "'attribute' must be given as key:value, got '" + filter + "'"); + } + result.put(filter.substring(0, separator), filter.substring(separator + 1)); + } + return result; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/IterationRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/IterationRQ.java new file mode 100644 index 0000000000..87c6780259 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/IterationRQ.java @@ -0,0 +1,42 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The run being reported: the iteration of the pipeline named next to it in {@link PipelineIterationRQ}. + *

+ * Example: {@code {"startedAt": "2026-09-19T10:13:00Z", + * "attributes": [{"key": "requirement", "value": "US-TMS-MIG-001", "type": "META"}], + * "stages": [{"stageKey": "create", "status": "Running"}]}}. Everything here belongs to this one iteration; + * facts about the pipeline itself (its repository) are sent beside it, not inside it. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IterationRQ { + + @Valid + @Schema(description = "Typed facts about this iteration: requirement, trigger, model, environment, links, tags, ...") + private List attributes; + + private Instant startedAt; + + private Instant finishedAt; + + @Schema(description = "Marks the iteration as a re-run of the given iteration of the same pipeline; " + + "a new iteration is created either way") + private Long rerunOfIterationId; + + @Valid + @Schema(description = "Stages known at creation time; may be empty - stages can be reported one by one afterwards") + private List stages; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineCompareRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineCompareRS.java new file mode 100644 index 0000000000..539697e4b7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineCompareRS.java @@ -0,0 +1,94 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Two iterations of one pipeline side by side. Nothing here knows what the iterations are about: stages + * are matched by name, metrics are the iterations' comparable attributes, and per-row detail is joined + * from the stages' result sections. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineCompareRS { + + private PipelineIterationDetailRS baseline; + private PipelineIterationDetailRS candidate; + + @Schema(description = "True when the comparison-key attributes of both iterations are equal (e.g. the same requirement), " + + "so their result rows can be compared case by case") + private boolean sameScope; + + @Schema(description = "Set when the iterations do not cover the same scope") + private String note; + + private List stages; + private List metrics; + @Schema(description = "Side-by-side rows of result sections present in both iterations; only filled when sameScope") + private List sections; + + public enum Verdict { + BETTER, WORSE, SAME, NOT_APPLICABLE + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class StageCompare { + private String name; + private PipelineStageSummaryRS baseline; + private PipelineStageSummaryRS candidate; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class MetricCompare { + private String key; + private String label; + private AttributeValueType valueType; + private String baseline; + private String candidate; + @Schema(description = "candidate - baseline, for numeric metrics present in both iterations") + private String delta; + private Verdict verdict; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class SectionCompare { + private String stageName; + private String sectionKey; + private String title; + private List columns; + private List rows; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RowCompare { + private String key; + private PipelineSection.Row baseline; + private PipelineSection.Row candidate; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationDetailRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationDetailRS.java new file mode 100644 index 0000000000..78e4ecdc06 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationDetailRS.java @@ -0,0 +1,46 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineIterationDetailRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long pipelineId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String pipelineName; + private String pipelineType; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer iterationNumber; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Derived status text of the iteration") + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StatusCategory statusCategory; + @Schema(description = "Typed facts about the iteration: META ones form the meta line and totals, TAG ones are chips") + private List attributes; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private PipelineUserRS startedBy; + @Schema(description = "The iteration this one is a re-run of") + private PipelineIterationRefRS rerunOf; + private Instant createdAt; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List stages; + @Schema(description = "Only set in the response to a report: what was accepted but not fully applied") + private List warnings; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationPatchRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationPatchRQ.java new file mode 100644 index 0000000000..a3c07f67f5 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationPatchRQ.java @@ -0,0 +1,33 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Partial update of an iteration; absent fields are left as they are. The iteration status is never set + * directly - it follows from the stages. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineIterationPatchRQ { + + @Valid + @Schema(description = "Attributes to add or replace by key; other attributes are kept") + private List attributes; + + @Schema(description = "Keys of attributes to remove") + private List removeAttributes; + + private Instant startedAt; + + private Instant finishedAt; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRQ.java new file mode 100644 index 0000000000..ec3a1475a2 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRQ.java @@ -0,0 +1,54 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A report of one iteration: the root describes the pipeline, {@link #iteration} describes the run. The + * pipeline is created by the first report that names it. + *

+ * Example: {@code {"name": "Test case generation", "type": "TEST_CASE_GENERATION", + * "attributes": [{"key": "repository", "value": "EPM-RPP/rp-tests", "type": "META"}], + * "iteration": {"startedAt": "2026-09-19T10:13:00Z", + * "attributes": [{"key": "requirement", "value": "US-TMS-MIG-001", "type": "META"}], + * "stages": [{"stageKey": "create", "status": "Running"}]}}}. The root {@code attributes} describe the pipeline + * (its repository, shown once in the group header); {@code iteration.attributes} describe this run + * (requirement, model, environment). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineIterationRQ { + + @NotBlank + @Size(max = 256) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Pipeline name, unique within the project; " + + "the pipeline is created by the first report that names it") + private String name; + + @Size(max = 64) + @Schema(description = "Kind of the pipeline (e.g. TEST_CASE_GENERATION); recorded when the pipeline is first " + + "reported and selects the behaviour plugged into that kind. Reporting a different type for an " + + "existing pipeline is rejected") + private String type; + + @Valid + @Schema(description = "Typed facts about the pipeline itself (e.g. its repository); added to or replacing the " + + "pipeline's attributes by key") + private List attributes; + + @NotNull + @Valid + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "The iteration being reported") + private IterationRQ iteration; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRefRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRefRS.java new file mode 100644 index 0000000000..81f8659321 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRefRS.java @@ -0,0 +1,18 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Reference to another iteration of the same pipeline, e.g. the one a re-run was started from. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineIterationRefRS { + private Long id; + private Integer iterationNumber; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationSummaryRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationSummaryRS.java new file mode 100644 index 0000000000..83a2ed85bb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationSummaryRS.java @@ -0,0 +1,47 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * An iteration as shown on a list card: header facts as attributes (META in the meta line, TAG as chips), + * stage chips and timing. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineIterationSummaryRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long pipelineId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String pipelineName; + private String pipelineType; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer iterationNumber; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Derived status text of the iteration") + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StatusCategory statusCategory; + private List attributes; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List stages; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private PipelineUserRS startedBy; + @Schema(description = "The iteration this one is a re-run of") + private PipelineIterationRefRS rerunOf; + private Instant createdAt; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineRS.java new file mode 100644 index 0000000000..a182e1ec68 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineRS.java @@ -0,0 +1,33 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + private String type; + private String description; + @Schema(description = "Typed facts about the pipeline itself, e.g. its repository") + private List attributes; + @Schema(description = "Settings read by the behaviour plugged into the pipeline, e.g. {\"autoReady\": {\"enabled\": true, \"threshold\": 90}}") + private Map settings; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private long iterationsCount; + private Instant createdAt; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineSettingsRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineSettingsRQ.java new file mode 100644 index 0000000000..240624a5cd --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineSettingsRQ.java @@ -0,0 +1,30 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Changes to a pipeline's settings. Each top-level entry replaces the entry of the same name; an entry set to + * {@code null} is removed; entries not mentioned stay as they are. + *

+ * Example: {@code {"settings": {"autoReady": {"enabled": true, "threshold": 90}}}} switches on Auto-Ready for + * the test cases the pipeline produces, and {@code {"settings": {"autoReady": null}}} switches the setting off + * again by removing it. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineSettingsRQ { + + @NotNull + @Schema(description = "Settings to set, by name. What a name means is up to the behaviour plugged into the " + + "pipeline, e.g. \"autoReady\" is read by test case Auto-Ready; a value the plugged-in behaviour does " + + "not accept is rejected", requiredMode = Schema.RequiredMode.REQUIRED) + private Map settings; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageAttemptRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageAttemptRS.java new file mode 100644 index 0000000000..60391f4d49 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageAttemptRS.java @@ -0,0 +1,35 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineLink; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineStageAttemptRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "1-based attempt number") + private Integer attempt; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StatusCategory statusCategory; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private List links; + private PipelineStepResultRS result; + private List attributes; + private List sections; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRQ.java new file mode 100644 index 0000000000..406e3c3539 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRQ.java @@ -0,0 +1,98 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.core.aifactory.model.PipelineLink; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A stage as reported by the pipeline, either with the iteration or on its own. {@code stageKey} is + * required when the stage arrives with an iteration; when it is reported on its own the key comes from + * the path. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStageRQ { + + @Size(max = 128) + @Schema(description = "Identifies the stage within the iteration; reporting an existing key updates the stage") + private String stageKey; + + @Size(max = 256) + private String name; + + @Schema(description = "Position among stages; defaults to the order of reporting") + private Integer sequence; + + @Schema(description = "Key of the stage this one is nested in; the parent must be reported in the same request " + + "or earlier") + private String parentStageKey; + + @NotBlank + @Size(max = 64) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Status text, stored exactly as reported") + private String status; + + @Schema(description = "What the status counts as; derived from well-known status texts when absent, " + + "UNKNOWN for anything else") + private StatusCategory statusCategory; + + @Schema(description = "Whether this stage's status counts towards the iteration status (default true); " + + "a stage that is evaluation only reports false") + private Boolean affectsIterationStatus; + + private Instant startedAt; + + private Instant finishedAt; + + @Valid + private List attributes; + + @Valid + @Schema(description = "Result tables of the stage") + private List sections; + + @Size(max = 128) + @Schema(description = "Short text for the stage chip in the iteration card, e.g. \"4 cases\", \"79\" or " + + "\"4 ↑\". Separate from the attributes, which stay numbers for totals and comparison") + private String summary; + + @Size(max = 512) + @Schema(description = "What the stage is doing or waiting for right now, e.g. 'Queued - waiting for runner'") + private String message; + + @Valid + @Schema(description = "Links out of the stage; the link to the run is derived from 'execution' and need not be repeated") + private List links; + + @Schema(description = "The raw output of the stage, kept exactly as received. Example: the grader's " + + "output {\"total_score\": 90, \"cases\": [...]} on a Grade stage") + private Map rawOutput; + + @Size(max = 128) + @Schema(description = "Name of the schema the raw output must follow. When ReportPortal has a schema with " + + "this name the raw output is checked against it; a raw output that does not match is rejected and the " + + "stage is marked Failed with the reason. Example: \"grade\" for the grader's output. " + + "Leave it out for a raw output that needs no check") + private String rawOutputSchemaName; + + private PipelineStepResultRQ result; + + @Valid + @Schema(description = "Where and how the stage ran, and what is needed to run it again") + private PipelineExecution execution; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRS.java new file mode 100644 index 0000000000..67756d7aeb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRS.java @@ -0,0 +1,69 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineLink; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineStageRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sequence; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "1-based number of the stage's current attempt; bumped by a retry or a new report") + private int attempt; + private Long parentStageId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Status text exactly as reported") + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StatusCategory statusCategory; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StageOwner ownedBy; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private List attributes; + @Schema(description = "Short text for the stage chip, e.g. \"4 cases\" or \"79\"; as reported") + private String summary; + @Schema(description = "What the stage is doing or waiting for right now") + private String message; + @Schema(description = "Whether the stage's status counts towards the iteration status; false for a stage that " + + "is evaluation only (e.g. Grade)") + private boolean affectsIterationStatus; + @Schema(description = "Things elsewhere in ReportPortal the stage worked on or produced, collected by ReportPortal from the " + + "cell targets of its sections (the agent sends each only once, in the row). " + + "Example: an Upload stage whose rows link TC-12 returns " + + "[{entityType: \"TEST_CASE\", entityId: \"TC-12\"}], which the UI shows as a link to that test case") + private List relatedEntities; + @Schema(description = "Result tables of the stage") + private List sections; + private PipelineStepResultRS result; + @Schema(description = "The raw output the stage reported, exactly as received") + private Map rawOutput; + private String rawOutputSchemaName; + @Schema(description = "Links out of the stage, e.g. to the run that executed it") + private List links; + @Schema(description = "Whether the stage can be run again through the handler of its execution type") + private boolean retryable; + @Schema(description = "Earlier finished attempts of this stage, oldest first; the current attempt is the stage itself") + private List attempts; + private Instant lastRetriedAt; + private Long lastRetriedBy; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRQ.java new file mode 100644 index 0000000000..3541a8b8bb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRQ.java @@ -0,0 +1,14 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStageRetryRQ { + private String comment; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRS.java new file mode 100644 index 0000000000..19273a165d --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRS.java @@ -0,0 +1,22 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStageRetryRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long stageId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StatusCategory statusCategory; + private String triggeredRunUrl; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageSummaryRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageSummaryRS.java new file mode 100644 index 0000000000..3752629f6c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageSummaryRS.java @@ -0,0 +1,50 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A stage as shown in a chip row: status plus its attributes (see summary for the chip text). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineStageSummaryRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sequence; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "1-based number of the stage's current attempt; bumped by a retry or a new report") + private int attempt; + private Long parentStageId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Status text exactly as reported") + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StatusCategory statusCategory; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private StageOwner ownedBy; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private List attributes; + @Schema(description = "Short text for the stage chip, e.g. \"4 cases\" or \"79\"; as reported") + private String summary; + @Schema(description = "What the stage is doing or waiting for right now") + private String message; + @Schema(description = "Whether the stage's status counts towards the iteration status; false for a stage that " + + "is evaluation only (e.g. Grade)") + private boolean affectsIterationStatus; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRQ.java new file mode 100644 index 0000000000..7c1a72b4dc --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRQ.java @@ -0,0 +1,18 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStepResultRQ { + @Size(max = 64) + private String resultType; + @Size(max = 255) + private String resultRef; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRS.java new file mode 100644 index 0000000000..2960dff0f5 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRS.java @@ -0,0 +1,19 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Pointer to an artifact the stage produced (e.g. type LAUNCH and its id); tabular results are + * carried by the stage's sections. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStepResultRS { + private String resultType; + private String resultRef; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineUserRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineUserRS.java new file mode 100644 index 0000000000..8ba11da74f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineUserRS.java @@ -0,0 +1,19 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * The user an iteration was started by. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineUserRS { + private Long id; + private String login; + private String fullName; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineWarningRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineWarningRS.java new file mode 100644 index 0000000000..7f1d0e147d --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineWarningRS.java @@ -0,0 +1,37 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Something in a report that was accepted but not fully applied. Only returned in the response to a report. + *

+ * Example: a stage row links {@code {entityType: "TEST_CASE", entityId: "TC-404"}} but the + * project has no test case TC-404. The report is still saved and the response carries + * {@code {code: "RELATED_ENTITY_NOT_FOUND", stageId: 12, entityType: "TEST_CASE", entityIds: ["TC-404"]}}. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineWarningRS { + + /** Related entities of a known type that could not be found, e.g. a test case id that does not exist. */ + public static final String RELATED_ENTITY_NOT_FOUND = "RELATED_ENTITY_NOT_FOUND"; + + /** What kind of problem this is, e.g. {@code RELATED_ENTITY_NOT_FOUND}. */ + private String code; + /** Human-readable explanation. */ + private String message; + /** The stage the problem belongs to. */ + private Long stageId; + /** For related-entity warnings: the kind of entity, e.g. {@code TEST_CASE}. */ + private String entityType; + /** For related-entity warnings: the ids that could not be handled, e.g. {@code ["TC-404"]}. */ + private List entityIds; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeDirection.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeDirection.java new file mode 100644 index 0000000000..6c9aecce8c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeDirection.java @@ -0,0 +1,14 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Which way a numeric attribute improves, used to colour a delta when two iterations are compared. + */ +@Schema(description = "HIGHER_BETTER - an increase is an improvement (score); LOWER_BETTER - a decrease is an " + + "improvement (cost, duration); NONE - the delta is informational") +public enum AttributeDirection { + HIGHER_BETTER, + LOWER_BETTER, + NONE +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeRollup.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeRollup.java new file mode 100644 index 0000000000..7edbb7e4ce --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeRollup.java @@ -0,0 +1,12 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * How an attribute reported on stages is summed up into the iteration. + */ +@Schema(description = "SUM - the iteration gets this attribute as the sum of its stages' values, unless the pipeline " + + "reported it on the iteration itself") +public enum AttributeRollup { + SUM +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeType.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeType.java new file mode 100644 index 0000000000..486aaf21eb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeType.java @@ -0,0 +1,19 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Where the UI puts an attribute. Any number of attributes of each kind is allowed. + *

+ * Example on an iteration: {@code requirement}, {@code trigger}, {@code model}, {@code suiteScore} and + * {@code cost} are {@link #META} (the line under the title), {@code env:beta5} is a {@link #TAG} (a chip), + * {@code requirementTitle} is {@link #NONE} (returned for the UI to pick by key, drawn nowhere by itself). + */ +@Schema(description = "META - shown on the summary line of its owner (an iteration's meta line, a stage card), " + + "ordered by 'order'; TAG - shown as a chip (e.g. env:beta5); NONE - returned and searchable but not drawn " + + "automatically, the UI may read it by key") +public enum AttributeType { + META, + TAG, + NONE +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeValueType.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeValueType.java new file mode 100644 index 0000000000..b637efa561 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeValueType.java @@ -0,0 +1,27 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * How an attribute value (or a result table cell) is to be interpreted and formatted. The value itself + * is always carried as text. + */ +@Schema(description = "TEXT, NUMBER, SCORE (a rating, higher usually better), MONEY (USD), DURATION (milliseconds), " + + "DATE (ISO-8601 instant), LINK (the url field is the target), STATUS (a status text, coloured like stage statuses)") +public enum AttributeValueType { + TEXT, + NUMBER, + SCORE, + MONEY, + DURATION, + DATE, + LINK, + STATUS; + + /** + * Whether values of this type can be subtracted in a comparison. + */ + public boolean isNumeric() { + return this == NUMBER || this == SCORE || this == MONEY || this == DURATION; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/SectionRole.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/SectionRole.java new file mode 100644 index 0000000000..4a052fb767 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/SectionRole.java @@ -0,0 +1,17 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Which part of the stage view a section belongs to. + *

+ * Example: on a Grade stage the table of graded test cases is a {@link #RESULT} section (the main panel), the + * token usage per model is a {@link #DETAILS} section (the side panel next to the stage's duration, CI job and + * cost). + */ +@Schema(description = "RESULT - what the stage produced, shown in the main panel (graded cases, upload report); " + + "DETAILS - facts about how the stage ran, shown beside it (token usage per model)") +public enum SectionRole { + RESULT, + DETAILS +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOwner.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOwner.java new file mode 100644 index 0000000000..666f9ff025 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOwner.java @@ -0,0 +1,20 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Who creates a stage and keeps it up to date. + *

+ * Example: in a test case generation iteration the Create, Grade and Upload stages are owned by the + * {@link #PIPELINE}: the agent's CI job reports them. The Review stage is owned by {@link #REPORTPORTAL}: + * ReportPortal creates it and recounts "2 / 3 Ready" whenever a reviewer changes a test case, so the CI job + * never reports it. + */ +@Schema(description = "PIPELINE - reported by the pipeline (e.g. Create, Grade, Upload); REPORTPORTAL - created and " + + "kept up to date by ReportPortal from other data (e.g. a Review stage counting how many test cases are Ready)") +public enum StageOwner { + /** Reported by the pipeline itself. */ + PIPELINE, + /** Calculated and maintained by ReportPortal; the pipeline cannot report it. */ + REPORTPORTAL +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategory.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategory.java new file mode 100644 index 0000000000..7b1b431223 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategory.java @@ -0,0 +1,105 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Collection; +import java.util.Locale; +import java.util.Set; + +/** + * Normalized meaning of a reported status. The status text itself is stored exactly as the pipeline + * reported it; the category is what aggregation and colouring rely on, so an unknown text such as + * "Queued · waiting for runner" stays displayable while the pipeline (or {@link #fromReported}) decides + * how it counts. + */ +@Schema(description = "Normalized category of a reported status. An iteration takes the worst category of the stages " + + "that affect its status: FAILED > RUNNING > WAITING > PENDING > PASSED. SKIPPED and UNKNOWN stages are ignored. " + + "Clients should render UNKNOWN neutrally (gray) and show the reported status text.") +public enum StatusCategory { + PENDING, + RUNNING, + WAITING, + PASSED, + FAILED, + SKIPPED, + UNKNOWN; + + private static final Set PENDING_TEXTS = Set.of("pending"); + private static final Set RUNNING_TEXTS = Set.of("running"); + private static final Set PASSED_TEXTS = Set.of("passed", "done", "completed", "success", "succeeded"); + private static final Set FAILED_TEXTS = Set.of("failed", "failure", "error"); + private static final Set SKIPPED_TEXTS = Set.of("skipped"); + private static final Set WAITING_TEXTS = Set.of("in progress", "in review", "needs human", "waiting"); + + /** + * Category of a status text the pipeline reported without an explicit category; anything not + * recognized is {@link #UNKNOWN}. + */ + public static StatusCategory fromReported(String status) { + if (status == null) { + return UNKNOWN; + } + var normalized = status.trim().toLowerCase(Locale.ROOT).replace('_', ' ').replace('-', ' '); + if (PENDING_TEXTS.contains(normalized)) { + return PENDING; + } + if (RUNNING_TEXTS.contains(normalized)) { + return RUNNING; + } + if (PASSED_TEXTS.contains(normalized)) { + return PASSED; + } + if (FAILED_TEXTS.contains(normalized)) { + return FAILED; + } + if (SKIPPED_TEXTS.contains(normalized)) { + return SKIPPED; + } + if (WAITING_TEXTS.contains(normalized)) { + return WAITING; + } + return UNKNOWN; + } + + /** + * Iteration category from the categories of the stages that affect it: + * FAILED > RUNNING > WAITING > PENDING > PASSED; SKIPPED and UNKNOWN are ignored. + * Nothing left to judge by is PENDING when there are no stages, UNKNOWN when only unknown ones + * were reported and PASSED when everything was skipped. + */ + public static StatusCategory aggregate(Collection categories) { + if (categories.isEmpty()) { + return PENDING; + } + if (categories.contains(FAILED)) { + return FAILED; + } + if (categories.contains(RUNNING)) { + return RUNNING; + } + if (categories.contains(WAITING)) { + return WAITING; + } + if (categories.contains(PENDING)) { + return PENDING; + } + if (categories.contains(PASSED)) { + return PASSED; + } + return categories.contains(SKIPPED) ? PASSED : UNKNOWN; + } + + /** + * Default text for a status derived (not reported), e.g. an iteration's. + */ + public String defaultLabel() { + return switch (this) { + case PENDING -> "Pending"; + case RUNNING -> "Running"; + case WAITING -> "Waiting"; + case PASSED -> "Passed"; + case FAILED -> "Failed"; + case SKIPPED -> "Skipped"; + case UNKNOWN -> "Unknown"; + }; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineIterationIngestedEvent.java b/src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineIterationIngestedEvent.java new file mode 100644 index 0000000000..a2e83a5794 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineIterationIngestedEvent.java @@ -0,0 +1,24 @@ +package com.epam.reportportal.base.core.aifactory.event; + +import lombok.Getter; + +/** + * Published after an ingested {@code PipelineIteration} (and its stages) has been + * persisted, intended for activity/audit history and any future downstream + * consumer. No {@code EventToActivityConverter} is registered for this event yet + * (see {@code EventObject.PIPELINE_ITERATION}) — publishing it today has no + * observable effect. Wiring an activity converter, following the pattern of + * {@code TestCaseCreatedEventConverter}, is a tracked follow-up. + */ +@Getter +public class PipelineIterationIngestedEvent { + private final Long iterationId; + private final Long pipelineId; + private final Long projectId; + + public PipelineIterationIngestedEvent(Long iterationId, Long pipelineId, Long projectId) { + this.iterationId = iterationId; + this.pipelineId = pipelineId; + this.projectId = projectId; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineStageRetryRequestedEvent.java b/src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineStageRetryRequestedEvent.java new file mode 100644 index 0000000000..6863e60b79 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineStageRetryRequestedEvent.java @@ -0,0 +1,27 @@ +package com.epam.reportportal.base.core.aifactory.event; + +import lombok.Getter; + +/** + * Published after a stage retry has been successfully triggered on the CI side. + * No {@code EventToActivityConverter} is registered for this event yet (see + * {@code EventObject.PIPELINE_STAGE}) — publishing it today has no observable + * effect. Wiring an activity converter, following the pattern of + * {@code TestCaseCreatedEventConverter}, is a tracked follow-up. + */ +@Getter +public class PipelineStageRetryRequestedEvent { + private final Long stageId; + private final Long iterationId; + private final Long projectId; + private final Long triggeredBy; + private final String comment; + + public PipelineStageRetryRequestedEvent(Long stageId, Long iterationId, Long projectId, Long triggeredBy, String comment) { + this.stageId = stageId; + this.iterationId = iterationId; + this.projectId = projectId; + this.triggeredBy = triggeredBy; + this.comment = comment; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java b/src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java new file mode 100644 index 0000000000..45bcfaef80 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java @@ -0,0 +1,292 @@ +package com.epam.reportportal.base.core.aifactory.mapper; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRefRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageAttemptRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStepResultRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineUserRS; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.core.aifactory.model.PipelineLink; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineRawOutput; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineExecutionData; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineLinks; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineRelatedEntities; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineSections; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import com.epam.reportportal.base.infrastructure.persistence.entity.user.User; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Maps the pipeline entities to their response models and builds stage entities from reports. + * Nothing here interprets attributes, sections or statuses beyond carrying them. + */ +@Component +public class PipelineMapper { + + public PipelineRS toPipelineRS(Pipeline pipeline, long iterationsCount) { + return PipelineRS.builder() + .id(pipeline.getId()) + .name(pipeline.getName()) + .type(pipeline.getType()) + .description(pipeline.getDescription()) + .attributes(attributeList(pipeline.getAttributes())) + .settings(pipeline.getSettings() == null ? null : pipeline.getSettings().getValues()) + .iterationsCount(iterationsCount) + .createdAt(pipeline.getCreatedAt()) + .build(); + } + + public PipelineIterationSummaryRS toIterationSummaryRS(PipelineIteration iteration, List attributes, + List stages, PipelineUserRS startedBy, PipelineIterationRefRS rerunOf) { + return PipelineIterationSummaryRS.builder() + .id(iteration.getId()) + .pipelineId(iteration.getPipeline().getId()) + .pipelineName(iteration.getPipeline().getName()) + .pipelineType(iteration.getPipeline().getType()) + .iterationNumber(iteration.getIterationNumber()) + .status(iteration.getStatus()) + .statusCategory(iteration.getStatusCategory()) + .attributes(attributes) + .stages(stages) + .startedAt(iteration.getStartedAt()) + .finishedAt(iteration.getFinishedAt()) + .durationMillis(durationMillis(iteration.getStartedAt(), iteration.getFinishedAt())) + .startedBy(startedBy) + .rerunOf(rerunOf) + .createdAt(iteration.getCreatedAt()) + .build(); + } + + public PipelineIterationDetailRS toIterationDetailRS(PipelineIteration iteration, List attributes, + List stages, + PipelineUserRS startedBy, PipelineIterationRefRS rerunOf) { + return PipelineIterationDetailRS.builder() + .id(iteration.getId()) + .pipelineId(iteration.getPipeline().getId()) + .pipelineName(iteration.getPipeline().getName()) + .pipelineType(iteration.getPipeline().getType()) + .iterationNumber(iteration.getIterationNumber()) + .status(iteration.getStatus()) + .statusCategory(iteration.getStatusCategory()) + .attributes(attributes) + .startedAt(iteration.getStartedAt()) + .finishedAt(iteration.getFinishedAt()) + .durationMillis(durationMillis(iteration.getStartedAt(), iteration.getFinishedAt())) + .startedBy(startedBy) + .rerunOf(rerunOf) + .createdAt(iteration.getCreatedAt()) + .stages(stages) + .build(); + } + + public PipelineUserRS toUserRS(User user) { + return PipelineUserRS.builder() + .id(user.getId()) + .login(user.getLogin()) + .fullName(user.getFullName()) + .build(); + } + + public PipelineStageSummaryRS toStageSummaryRS(PipelineStage stage) { + return PipelineStageSummaryRS.builder() + .id(stage.getId()) + .name(stage.getName()) + .sequence(stage.getSequence()) + .attempt(stage.getAttempt()) + .parentStageId(stage.getParentStageId()) + .status(stage.getStatus()) + .statusCategory(stage.getStatusCategory()) + .ownedBy(stage.getOwnedBy()) + .startedAt(stage.getStartedAt()) + .finishedAt(stage.getFinishedAt()) + .durationMillis(durationMillis(stage.getStartedAt(), stage.getFinishedAt())) + .attributes(attributeList(stage.getAttributes())) + .summary(stage.getSummary()) + .message(stage.getMessage()) + .affectsIterationStatus(stage.isAffectsIterationStatus()) + .build(); + } + + public PipelineStageRS toStageRS(PipelineStage stage) { + return PipelineStageRS.builder() + .id(stage.getId()) + .name(stage.getName()) + .sequence(stage.getSequence()) + .attempt(stage.getAttempt()) + .parentStageId(stage.getParentStageId()) + .status(stage.getStatus()) + .statusCategory(stage.getStatusCategory()) + .ownedBy(stage.getOwnedBy()) + .startedAt(stage.getStartedAt()) + .finishedAt(stage.getFinishedAt()) + .durationMillis(durationMillis(stage.getStartedAt(), stage.getFinishedAt())) + .attributes(attributeList(stage.getAttributes())) + .summary(stage.getSummary()) + .message(stage.getMessage()) + .affectsIterationStatus(stage.isAffectsIterationStatus()) + .relatedEntities(relatedEntityList(stage.getRelatedEntities())) + .sections(sectionList(stage.getSections())) + .result(toStepResultRS(stage.getResultType(), stage.getResultRef())) + .rawOutput(stage.getRawOutput() == null ? null : stage.getRawOutput().getContent()) + .rawOutputSchemaName(stage.getRawOutputSchemaName()) + .links(allLinks(stage)) + .retryable(execution(stage.getExecution()) != null && execution(stage.getExecution()).isRetryable()) + .lastRetriedAt(stage.getLastRetriedAt()) + .lastRetriedBy(stage.getLastRetriedBy()) + .build(); + } + + public PipelineStageAttemptRS toAttemptRS(PipelineStageAttempt attempt) { + return PipelineStageAttemptRS.builder() + .attempt(attempt.getAttempt()) + .status(attempt.getStatus()) + .statusCategory(attempt.getStatusCategory()) + .startedAt(attempt.getStartedAt()) + .finishedAt(attempt.getFinishedAt()) + .durationMillis(durationMillis(attempt.getStartedAt(), attempt.getFinishedAt())) + .links(runLinks(attempt.getExecution())) + .result(toStepResultRS(attempt.getResultType(), attempt.getResultRef())) + .attributes(attributeList(attempt.getAttributes())) + .sections(sectionList(attempt.getSections())) + .build(); + } + + /** + * Builds a new (unsaved) stage entity from its report. The caller sets {@code iteration} and, once + * ids exist, {@code parentStageId}/{@code path}; test case links are resolved and persisted + * separately by the service (display ids need a repository lookup). + */ + public PipelineStage toStageEntity(PipelineStageRQ rq) { + var stage = new PipelineStage(); + stage.setStageKey(rq.getStageKey()); + stage.setOwnedBy(StageOwner.PIPELINE); + applyStage(stage, rq); + return stage; + } + + /** + * Copies what the pipeline reported onto the stage; the stage key, owner, attempt and tree position + * are not touched. A report replaces the previous one as a whole - what it does not mention is cleared, + * except the name and the counting flag which keep their value when absent. + */ + public void applyStage(PipelineStage stage, PipelineStageRQ rq) { + if (rq.getName() != null || stage.getName() == null) { + stage.setName(rq.getName() != null ? rq.getName() : rq.getStageKey()); + } + if (rq.getSequence() != null) { + stage.setSequence(rq.getSequence()); + } + stage.setStatus(rq.getStatus()); + stage.setStatusCategory(rq.getStatusCategory() != null ? rq.getStatusCategory() + : StatusCategory.fromReported(rq.getStatus())); + if (rq.getAffectsIterationStatus() != null) { + stage.setAffectsIterationStatus(rq.getAffectsIterationStatus()); + } + stage.setStartedAt(rq.getStartedAt()); + stage.setFinishedAt(rq.getFinishedAt()); + stage.setAttributes(rq.getAttributes() == null ? null : new PipelineAttributes(rq.getAttributes())); + stage.setSections(rq.getSections() == null ? null : new PipelineSections(rq.getSections())); + stage.setSummary(rq.getSummary()); + stage.setMessage(rq.getMessage()); + var related = PipelineSection.entityReferences(rq.getSections()); + stage.setRelatedEntities(related.isEmpty() ? null : new PipelineRelatedEntities(related)); + stage.setLinks(rq.getLinks() == null || rq.getLinks().isEmpty() ? null : new PipelineLinks(rq.getLinks())); + stage.setRawOutput(rq.getRawOutput() == null ? null : new PipelineRawOutput(rq.getRawOutput())); + stage.setRawOutputSchemaName(rq.getRawOutputSchemaName()); + stage.setResultType(rq.getResult() == null ? null : rq.getResult().getResultType()); + stage.setResultRef(rq.getResult() == null ? null : rq.getResult().getResultRef()); + if (rq.getExecution() != null) { + stage.setExecution(new PipelineExecutionData(rq.getExecution())); + } + } + + public List attributeList(PipelineAttributes attributes) { + return attributes == null || attributes.getItems().isEmpty() ? null : attributes.getItems(); + } + + public List relatedEntityList(PipelineRelatedEntities relatedEntities) { + return relatedEntities == null || relatedEntities.getItems().isEmpty() ? null : relatedEntities.getItems(); + } + + public List sectionList(PipelineSections sections) { + return sections == null || sections.getItems().isEmpty() ? null : sections.getItems(); + } + + private PipelineStepResultRS toStepResultRS(String resultType, String resultRef) { + if (resultType == null && resultRef == null) { + return null; + } + return PipelineStepResultRS.builder().resultType(resultType).resultRef(resultRef).build(); + } + + /** + * The links the stage was given plus the CI job link derived from its CI fields. + */ + private List allLinks(PipelineStage stage) { + var derived = runLinks(stage.getExecution()); + var given = stage.getLinks() == null ? List.of() : stage.getLinks().getItems(); + if (given.isEmpty()) { + return derived; + } + var all = new ArrayList(given); + if (derived != null) { + all.addAll(derived); + } + return all; + } + + private PipelineExecution execution(PipelineExecutionData data) { + return data == null ? null : data.getContent(); + } + + /** + * The link to the page of the run that executed the stage, if it reported one. + */ + private List runLinks(PipelineExecutionData data) { + var execution = execution(data); + if (execution == null || execution.getRunUrl() == null || execution.getRunUrl().isBlank()) { + return null; + } + var links = new ArrayList(); + links.add(PipelineLink.builder() + .type(PipelineLink.RUN) + .url(execution.getRunUrl()) + .label(execution.getExternalRunId() == null ? null : "#" + execution.getExternalRunId()) + .build()); + return links; + } + + private Long durationMillis(Instant startedAt, Instant finishedAt) { + if (startedAt == null || finishedAt == null) { + return null; + } + return Duration.between(startedAt, finishedAt).toMillis(); + } + + /** + * Convenience for callers that look users up in bulk. + */ + public Map toUserMap(List users) { + var result = new java.util.HashMap(); + users.forEach(user -> result.put(user.getId(), toUserRS(user))); + return result; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineAttribute.java b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineAttribute.java new file mode 100644 index 0000000000..a0358b184b --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineAttribute.java @@ -0,0 +1,86 @@ +package com.epam.reportportal.base.core.aifactory.model; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeDirection; +import com.epam.reportportal.base.core.aifactory.enums.AttributeRollup; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A typed, displayable fact about a pipeline, iteration, stage or stage attempt: the requirement an + * iteration was run for, its model, cost or score, an {@code env:beta5} tag, a link to the CI run. The + * type says where the UI puts it, the value type how to format it; the optional hints let ReportPortal + * aggregate and compare attributes without knowing what they mean. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineAttribute implements Serializable { + + @NotBlank + @Size(max = 128) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Identifies the attribute within its owner; " + + "reporting an attribute with an existing key replaces it") + private String key; + + @Size(max = 256) + @Schema(description = "Human readable name; the key is shown when absent") + private String label; + + @Size(max = 2048) + @Schema(description = "Value as text; its interpretation is given by valueType") + private String value; + + @Size(max = 1024) + @Schema(description = "Longer text that belongs to the value. Example: attribute requirement = " + + "\"US-TMS-MIG-001\" with description \"Test Case Library empty state display and navigation panel\"; " + + "the card shows the value, the details header shows both") + private String description; + + @Builder.Default + @Schema(description = "Placement hint for the UI (default TAG)") + private AttributeType type = AttributeType.TAG; + + @Builder.Default + @Schema(description = "How to interpret and format the value (default TEXT)") + private AttributeValueType valueType = AttributeValueType.TEXT; + + @Size(max = 2048) + @Schema(description = "Link target, used with valueType LINK") + private String url; + + @Schema(description = "Position among attributes of the same type (lower first); attributes without it follow " + + "in the reported order") + private Integer order; + + @Schema(description = "For numeric attributes: which direction is an improvement when iterations are compared") + private AttributeDirection direction; + + @Schema(description = "Stage attributes only: sum this attribute over the stages into the iteration") + private AttributeRollup rollup; + + @Schema(description = "Marks the attribute that tells what the iteration is about (e.g. the requirement). " + + "Iterations whose comparison-key attributes are equal cover the same scope, so Compare lines up their " + + "result rows case by case; otherwise it only notes that the scopes differ") + private boolean comparisonKey; + + /** + * Token stored in the iteration's attribute index for an attribute: control characters around the + * lower-cased key and value, so an exact {@code key=value} filter is a plain substring match that + * cannot hit part of another key or value. + */ + public static String indexToken(String key, String value) { + return "\u0001" + key.trim().toLowerCase(java.util.Locale.ROOT) + "\u0002" + + (value == null ? "" : value.trim().toLowerCase(java.util.Locale.ROOT)) + "\u0001"; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineExecution.java b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineExecution.java new file mode 100644 index 0000000000..e8d213a042 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineExecution.java @@ -0,0 +1,62 @@ +package com.epam.reportportal.base.core.aifactory.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.Serializable; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Where and how a stage ran, as far as ReportPortal needs to know to show a link to the run and to run the + * stage again. ReportPortal does not interpret {@code retryParameters}; only the retry handler registered + * for {@code executorType} does. + *

+ * Example, a stage that ran as a GitHub Actions job: + *

+ * {
+ *   "executorType": "GITHUB_ACTIONS",
+ *   "externalRunId": "78911",
+ *   "runUrl": "https://github.com/org/repo/actions/runs/123456/job/78911",
+ *   "retryable": true,
+ *   "retryParameters": {"repo": "org/repo", "workflowRef": "ai-pipeline.yml"}
+ * }
+ * 
+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineExecution implements Serializable { + + @NotBlank + @Size(max = 64) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, + description = "Kind of system that ran the stage, e.g. GITHUB_ACTIONS or GITLAB_CI. " + + "It selects the handler that can run the stage again") + private String executorType; + + @Size(max = 128) + @Schema(description = "Id of this run of the stage in the system that ran it, e.g. the CI job id \"78911\". " + + "A finished stage reported again with a different one is a new attempt; with the same one it just " + + "updates the current attempt") + private String externalRunId; + + @Size(max = 1024) + @Schema(description = "Page of this run, shown as a link of the stage, " + + "e.g. \"https://github.com/org/repo/actions/runs/123456/job/78911\"") + private String runUrl; + + @Schema(description = "Whether the stage can be run again through the retry handler of its executor type") + private boolean retryable; + + @Size(max = 20) + @Schema(description = "Whatever the retry handler of this executor type needs to run the stage again. " + + "Example for GITHUB_ACTIONS: {\"repo\": \"org/repo\", \"workflowRef\": \"ai-pipeline.yml\"}") + private Map retryParameters; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineLink.java b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineLink.java new file mode 100644 index 0000000000..741d4a7748 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineLink.java @@ -0,0 +1,38 @@ +package com.epam.reportportal.base.core.aifactory.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A link out of a stage: to a page elsewhere ({@code url}, e.g. the run that executed it) or to something + * in ReportPortal ({@code target}, which ReportPortal does not interpret - the UI resolves it). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineLink implements Serializable { + + public static final String RUN = "RUN"; + + @NotBlank + @Size(max = 64) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Kind of link, e.g. RUN") + private String type; + @Size(max = 2048) + private String url; + @Size(max = 255) + private String label; + @Valid + @Schema(description = "A thing inside ReportPortal the link leads to; type and ref are opaque to the API") + private PipelineSection.EntityReference target; +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineSection.java b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineSection.java new file mode 100644 index 0000000000..3b7279909f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineSection.java @@ -0,0 +1,200 @@ +package com.epam.reportportal.base.core.aifactory.model; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.enums.SectionRole; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.Serializable; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A named table a stage reports about what it did - graded cases, an upload report, token usage per + * model, fix rounds, anything. The UI renders every section with the same table component, so a new + * kind of result needs no new endpoint or field. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PipelineSection implements Serializable { + + @NotBlank + @Size(max = 128) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Identifies the section within its stage; " + + "reporting a section with an existing key replaces it") + private String key; + + @Size(max = 256) + private String title; + + @Builder.Default + @Schema(description = "RESULT (default) for what the stage produced, DETAILS for how it ran, e.g. token usage") + private SectionRole role = SectionRole.RESULT; + + @Valid + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List columns; + + @Valid + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List rows; + + /** + * The distinct entities the cells of the given sections point to, in the order they appear. + *

+ * Example: an upload report whose rows carry the targets TEST_CASE TC-12 and TEST_CASE TC-13 yields those two. + */ + public static List entityReferences(List sections) { + var references = new LinkedHashSet(); + if (sections != null) { + for (var section : sections) { + for (var row : section.getRows() == null ? List.of() : section.getRows()) { + for (var cell : row.getCells() == null ? List.of() : row.getCells().values()) { + if (cell != null && cell.getTarget() != null) { + references.add(cell.getTarget()); + } + } + } + } + } + return new java.util.ArrayList<>(references); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Column implements Serializable { + + @NotBlank + @Size(max = 128) + private String key; + + @Size(max = 256) + private String label; + + @Builder.Default + private AttributeValueType valueType = AttributeValueType.TEXT; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Row implements Serializable { + + @Size(max = 256) + @Schema(description = "Identifies the row within the section; rows with the same key in two iterations " + + "are matched when they are compared") + private String key; + + @Valid + @Schema(description = "Cells by column key. A cell may be sent as a bare scalar instead of an object") + private Map cells; + + @Valid + @Schema(description = "What the row shows when it is expanded. Example for a graded test case: " + + "[{heading: \"Clear expected results 17/20\", items: [\"Step 3 does not state which fields the modal " + + "shows.\"]}]") + private List details; + } + + /** + * One block of an expanded row: a heading and the points under it. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Detail implements Serializable { + + @Size(max = 256) + private String heading; + + @Size(max = 100) + @Schema(description = "The points under the heading, e.g. failure reasons") + private List<@Size(max = 2048) String> items; + } + + /** + * A table cell: a value, optionally with a link or a reference to an entity the UI knows how to open. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class Cell implements Serializable { + + @Size(max = 2048) + private String value; + + @Size(max = 2048) + private String url; + + @Valid + private EntityReference target; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static Cell fromJson(JsonNode node) { + if (node == null || node.isNull()) { + return new Cell(); + } + if (!node.isObject()) { + return Cell.builder().value(node.asText()).build(); + } + var cell = new Cell(); + cell.setValue(text(node, "value")); + cell.setUrl(text(node, "url")); + var target = node.get("target"); + if (target != null && target.isObject()) { + cell.setTarget(new EntityReference(text(target, "entityType"), text(target, "entityId"))); + } + return cell; + } + + private static String text(JsonNode node, String field) { + var child = node.get(field); + return child == null || child.isNull() ? null : child.asText(); + } + } + + /** + * A thing owned by another part of ReportPortal, named by its kind and its id. ReportPortal's pipeline + * layer does not interpret it; a resolver or the UI does. + *

+ * Example: {@code {entityType: "TEST_CASE", entityId: "TC-12"}} is test case TC-12 of the Test Case + * Library; a table cell with that target is shown as a link to the test case. + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class EntityReference implements Serializable { + + /** Kind of entity, e.g. {@code TEST_CASE}. */ + @NotBlank + @Size(max = 64) + private String entityType; + + /** Id of the entity as its owner knows it, e.g. the display id {@code TC-12}. */ + @NotBlank + @Size(max = 255) + private String entityId; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprint.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprint.java new file mode 100644 index 0000000000..6bc44f3d7f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprint.java @@ -0,0 +1,45 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Stable SHA-256 digest of an ingest request, used to tell a genuine retry (same + * {@code Idempotency-Key}, same body) from a key reused for a different payload. Map entries are + * ordered by key so the digest does not depend on the order a client happened to serialize them in. + */ +final class IngestFingerprint { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .findAndRegisterModules() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + + private IngestFingerprint() { + } + + static String of(PipelineIterationRQ rq) { + try { + var digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(MAPPER.writeValueAsBytes(rq))); + } catch (JsonProcessingException | NoSuchAlgorithmException e) { + throw new IllegalStateException("Cannot fingerprint ingest request", e); + } + } + + /** + * Size of the payload once serialized, for the size limit on reports. + */ + static long sizeInBytes(Object payload) { + try { + return MAPPER.writeValueAsBytes(payload).length; + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot measure report payload", e); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupport.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupport.java new file mode 100644 index 0000000000..9ca3939127 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupport.java @@ -0,0 +1,162 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeRollup; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Operations on typed attributes that do not depend on what the attributes mean: normalizing a reported + * list, merging, deriving the list-search text and summing flagged stage attributes into the iteration. + */ +final class PipelineAttributeSupport { + + static final int MAX_ATTRIBUTES = 100; + + private PipelineAttributeSupport() { + } + + /** + * Applies defaults and keeps the last attribute reported for each key, in first-seen order. + */ + static List normalize(List reported) { + if (reported == null) { + return List.of(); + } + var byKey = new LinkedHashMap(); + for (var attribute : reported) { + if (attribute == null || attribute.getKey() == null || attribute.getKey().isBlank()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Attribute 'key' must not be blank"); + } + var copy = PipelineAttribute.builder() + .key(attribute.getKey().trim()) + .label(attribute.getLabel()) + .value(attribute.getValue()) + .description(attribute.getDescription()) + .type(attribute.getType() == null ? AttributeType.TAG : attribute.getType()) + .valueType(attribute.getValueType() == null ? AttributeValueType.TEXT : attribute.getValueType()) + .url(attribute.getUrl()) + .order(attribute.getOrder()) + .direction(attribute.getDirection()) + .rollup(attribute.getRollup()) + .comparisonKey(attribute.isComparisonKey()) + .build(); + byKey.put(copy.getKey(), copy); + } + if (byKey.size() > MAX_ATTRIBUTES) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "At most " + MAX_ATTRIBUTES + " attributes are allowed"); + } + return List.copyOf(byKey.values()); + } + + static PipelineAttributes toColumn(List normalized) { + return normalized.isEmpty() ? null : new PipelineAttributes(normalized); + } + + /** + * Existing attributes with {@code updates} added or replaced by key and {@code removeKeys} dropped. + */ + static List merge(PipelineAttributes existing, List updates, + Collection removeKeys) { + var byKey = new LinkedHashMap(); + if (existing != null) { + existing.getItems().forEach(attribute -> byKey.put(attribute.getKey(), attribute)); + } + if (removeKeys != null) { + removeKeys.forEach(byKey::remove); + } + normalize(updates).forEach(attribute -> byKey.put(attribute.getKey(), attribute)); + if (byKey.size() > MAX_ATTRIBUTES) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "At most " + MAX_ATTRIBUTES + " attributes are allowed"); + } + return List.copyOf(byKey.values()); + } + + /** + * Lower-cased attribute values, space separated, for the case-insensitive list search. + */ + static String searchText(List attributes) { + var text = attributes.stream() + .map(PipelineAttribute::getValue) + .filter(Objects::nonNull) + .filter(value -> !value.isBlank()) + .collect(Collectors.joining(" ")) + .toLowerCase(Locale.ROOT); + return text.isEmpty() ? null : text; + } + + /** + * One lower-cased token per attribute (see {@link PipelineAttribute#indexToken}), for the exact + * attribute filter. + */ + static String attributeIndex(List attributes) { + var index = attributes.stream() + .map(attribute -> PipelineAttribute.indexToken(attribute.getKey(), attribute.getValue())) + .collect(Collectors.joining()); + return index.isEmpty() ? null : index; + } + + /** + * Attributes the stages flagged with {@link AttributeRollup#SUM}, summed over the stages, that the + * iteration does not already carry under the same key. + */ + static List rollup(List stages, Set reservedKeys) { + var sums = new LinkedHashMap(); + var templates = new LinkedHashMap(); + for (var stage : stages) { + if (stage.getAttributes() == null) { + continue; + } + for (var attribute : stage.getAttributes().getItems()) { + if (attribute.getRollup() != AttributeRollup.SUM || reservedKeys.contains(attribute.getKey())) { + continue; + } + var number = parse(attribute.getValue()); + if (number == null) { + continue; + } + sums.merge(attribute.getKey(), number, BigDecimal::add); + templates.putIfAbsent(attribute.getKey(), attribute); + } + } + return sums.entrySet().stream() + .map(entry -> { + var template = templates.get(entry.getKey()); + return PipelineAttribute.builder() + .key(template.getKey()) + .label(template.getLabel()) + .value(entry.getValue().stripTrailingZeros().toPlainString()) + .type(AttributeType.META) + .valueType(template.getValueType()) + .order(template.getOrder()) + .direction(template.getDirection()) + .build(); + }) + .toList(); + } + + static BigDecimal parse(String value) { + if (value == null || value.isBlank()) { + return null; + } + try { + return new BigDecimal(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparator.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparator.java new file mode 100644 index 0000000000..73e4c2bfe9 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparator.java @@ -0,0 +1,208 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageSummaryRS; +import com.epam.reportportal.base.core.aifactory.enums.AttributeDirection; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Builds the comparison of two iterations from what they carry, without knowing what they are about: + * stages are matched by name, metrics are the numeric attributes, per-row detail comes from result + * sections both iterations have, and "same scope" means equal comparison-key attributes. + */ +@Component +public class PipelineComparator { + + static final String DIFFERENT_SCOPE_NOTE = "Different scope - compare trends, not individual rows"; + + public PipelineCompareRS compare(PipelineIterationDetailRS baseline, PipelineIterationDetailRS candidate) { + var sameScope = sameScope(baseline, candidate); + return PipelineCompareRS.builder() + .baseline(baseline) + .candidate(candidate) + .sameScope(sameScope) + .note(sameScope ? null : DIFFERENT_SCOPE_NOTE) + .stages(compareStages(baseline, candidate)) + .metrics(compareMetrics(baseline, candidate)) + .sections(sameScope ? compareSections(baseline, candidate) : List.of()) + .build(); + } + + private boolean sameScope(PipelineIterationDetailRS baseline, PipelineIterationDetailRS candidate) { + var baselineScope = comparisonKeys(baseline); + var candidateScope = comparisonKeys(candidate); + return !baselineScope.isEmpty() && baselineScope.equals(candidateScope); + } + + private Set comparisonKeys(PipelineIterationDetailRS iteration) { + if (iteration.getAttributes() == null) { + return Set.of(); + } + return iteration.getAttributes().stream() + .filter(PipelineAttribute::isComparisonKey) + .map(attribute -> attribute.getKey() + "=" + attribute.getValue()) + .collect(Collectors.toSet()); + } + + private List compareStages(PipelineIterationDetailRS baseline, + PipelineIterationDetailRS candidate) { + var baselineByName = topLevelSummaries(baseline); + var candidateByName = topLevelSummaries(candidate); + var names = new LinkedHashSet(candidateByName.keySet()); + names.addAll(baselineByName.keySet()); + return names.stream() + .map(name -> PipelineCompareRS.StageCompare.builder() + .name(name) + .baseline(baselineByName.get(name)) + .candidate(candidateByName.get(name)) + .build()) + .toList(); + } + + private Map topLevelSummaries(PipelineIterationDetailRS iteration) { + var result = new LinkedHashMap(); + iteration.getStages().stream() + .filter(stage -> stage.getParentStageId() == null) + .forEach(stage -> result.putIfAbsent(stage.getName(), summaryOf(stage))); + return result; + } + + private PipelineStageSummaryRS summaryOf(PipelineStageRS stage) { + return PipelineStageSummaryRS.builder() + .id(stage.getId()) + .name(stage.getName()) + .sequence(stage.getSequence()) + .attempt(stage.getAttempt()) + .parentStageId(stage.getParentStageId()) + .status(stage.getStatus()) + .statusCategory(stage.getStatusCategory()) + .ownedBy(stage.getOwnedBy()) + .startedAt(stage.getStartedAt()) + .finishedAt(stage.getFinishedAt()) + .durationMillis(stage.getDurationMillis()) + .attributes(stage.getAttributes()) + .summary(stage.getSummary()) + .message(stage.getMessage()) + .affectsIterationStatus(stage.isAffectsIterationStatus()) + .build(); + } + + private List compareMetrics(PipelineIterationDetailRS baseline, + PipelineIterationDetailRS candidate) { + var baselineByKey = numericAttributes(baseline); + var candidateByKey = numericAttributes(candidate); + var keys = new LinkedHashSet(candidateByKey.keySet()); + keys.addAll(baselineByKey.keySet()); + var metrics = new ArrayList(); + for (var key : keys) { + var before = baselineByKey.get(key); + var after = candidateByKey.get(key); + var template = after != null ? after : before; + var beforeNumber = before == null ? null : PipelineAttributeSupport.parse(before.getValue()); + var afterNumber = after == null ? null : PipelineAttributeSupport.parse(after.getValue()); + BigDecimal delta = beforeNumber != null && afterNumber != null ? afterNumber.subtract(beforeNumber) : null; + var direction = after != null && after.getDirection() != null ? after.getDirection() + : before != null ? before.getDirection() : null; + metrics.add(PipelineCompareRS.MetricCompare.builder() + .key(key) + .label(template.getLabel() != null ? template.getLabel() : key) + .valueType(template.getValueType()) + .baseline(before == null ? null : before.getValue()) + .candidate(after == null ? null : after.getValue()) + .delta(delta == null ? null : delta.stripTrailingZeros().toPlainString()) + .verdict(verdict(delta, direction)) + .build()); + } + return metrics; + } + + private Map numericAttributes(PipelineIterationDetailRS iteration) { + if (iteration.getAttributes() == null) { + return Map.of(); + } + return iteration.getAttributes().stream() + .filter(attribute -> attribute.getValueType() != null && attribute.getValueType().isNumeric()) + .collect(Collectors.toMap(PipelineAttribute::getKey, Function.identity(), (a, b) -> b, LinkedHashMap::new)); + } + + PipelineCompareRS.Verdict verdict(BigDecimal delta, AttributeDirection direction) { + if (delta == null || direction == null || direction == AttributeDirection.NONE) { + return PipelineCompareRS.Verdict.NOT_APPLICABLE; + } + int sign = delta.signum(); + if (sign == 0) { + return PipelineCompareRS.Verdict.SAME; + } + boolean increased = sign > 0; + return increased == (direction == AttributeDirection.HIGHER_BETTER) + ? PipelineCompareRS.Verdict.BETTER : PipelineCompareRS.Verdict.WORSE; + } + + private List compareSections(PipelineIterationDetailRS baseline, + PipelineIterationDetailRS candidate) { + var baselineStages = byName(baseline); + var result = new ArrayList(); + for (var candidateStage : candidate.getStages()) { + var baselineStage = baselineStages.get(candidateStage.getName()); + if (baselineStage == null || candidateStage.getSections() == null || baselineStage.getSections() == null) { + continue; + } + for (var candidateSection : candidateStage.getSections()) { + baselineStage.getSections().stream() + .filter(section -> Objects.equals(section.getKey(), candidateSection.getKey())) + .findFirst() + .ifPresent(baselineSection -> result.add(PipelineCompareRS.SectionCompare.builder() + .stageName(candidateStage.getName()) + .sectionKey(candidateSection.getKey()) + .title(candidateSection.getTitle()) + .columns(candidateSection.getColumns()) + .rows(joinRows(baselineSection, candidateSection)) + .build())); + } + } + return result; + } + + private Map byName(PipelineIterationDetailRS iteration) { + return iteration.getStages().stream() + .collect(Collectors.toMap(PipelineStageRS::getName, Function.identity(), (a, b) -> a, LinkedHashMap::new)); + } + + private List joinRows(PipelineSection baseline, PipelineSection candidate) { + var baselineRows = keyedRows(baseline); + var candidateRows = keyedRows(candidate); + var keys = new LinkedHashSet(candidateRows.keySet()); + keys.addAll(baselineRows.keySet()); + return keys.stream() + .map(key -> PipelineCompareRS.RowCompare.builder() + .key(key) + .baseline(baselineRows.get(key)) + .candidate(candidateRows.get(key)) + .build()) + .toList(); + } + + private Map keyedRows(PipelineSection section) { + var rows = new LinkedHashMap(); + if (section.getRows() != null) { + section.getRows().stream() + .filter(row -> row.getKey() != null) + .forEach(row -> rows.putIfAbsent(row.getKey(), row)); + } + return rows; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationFilter.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationFilter.java new file mode 100644 index 0000000000..ae915fc2a8 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationFilter.java @@ -0,0 +1,20 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import java.util.Map; +import java.util.Set; + +/** + * Optional narrowing of the iteration list. Every field is optional; blank values are ignored. + * + * @param search case-insensitive substring over the iteration's attribute values, or exact iteration + * number (a leading {@code #} is ignored) + * @param statuses iteration status category is any of these + * @param attributes exact attribute filters ({@code key -> value}); every one must match + */ +public record PipelineIterationFilter(String search, Set statuses, Map attributes) { + + public static PipelineIterationFilter none() { + return new PipelineIterationFilter(null, null, null); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationIngestResult.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationIngestResult.java new file mode 100644 index 0000000000..1c72e0e92f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationIngestResult.java @@ -0,0 +1,13 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; + +/** + * Outcome of an ingest call. + * + * @param iteration the created (or, on a replay, previously created) iteration + * @param replayed true when an earlier request with the same Idempotency-Key was answered again + * instead of creating anything + */ +public record PipelineIterationIngestResult(PipelineIterationDetailRS iteration, boolean replayed) { +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresher.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresher.java new file mode 100644 index 0000000000..4225a32372 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresher.java @@ -0,0 +1,130 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Brings what ReportPortal derives for an iteration up to date: the stages owned by contributors and the + * iteration's own status, which is the worst category among the stages that count towards it. Called + * after every report and whenever something an owned stage depends on changes. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PipelineIterationRefresher { + + private final PipelineIterationRepository iterationRepository; + private final PipelineStageRepository stageRepository; + private final List contributors; + + @Transactional + public PipelineIteration refresh(PipelineIteration iteration) { + var pipeline = iteration.getPipeline(); + var stages = stageRepository.findByIterationIdOrderBySequenceAsc(iteration.getId()); + var applicable = contributors.stream().filter(contributor -> contributor.supports(pipeline)).toList(); + + for (var contributor : applicable) { + for (var contributed : contributor.contribute(iteration, stages)) { + var isNew = contributed.getId() == null; + var saved = stageRepository.save(contributed); + if (isNew) { + saved.setPath(String.valueOf(saved.getId())); + saved = stageRepository.save(saved); + stages = append(stages, saved); + } + } + } + + var contributedAttributes = new java.util.ArrayList(); + for (var contributor : applicable) { + contributedAttributes.addAll(contributor.iterationAttributes(iteration, stages)); + } + if (!contributedAttributes.isEmpty()) { + var merged = PipelineAttributeSupport.merge(iteration.getAttributes(), contributedAttributes, null); + iteration.setAttributes(PipelineAttributeSupport.toColumn(merged)); + iteration.setSearchText(PipelineAttributeSupport.searchText(merged)); + iteration.setAttributeIndex(PipelineAttributeSupport.attributeIndex(merged)); + } + + var categories = stages.stream() + .filter(PipelineStage::isAffectsIterationStatus) + .map(PipelineStage::getStatusCategory) + .filter(category -> category != StatusCategory.SKIPPED && category != StatusCategory.UNKNOWN) + .collect(Collectors.toCollection(java.util.HashSet::new)); + // A stage that does not count (e.g. an evaluation) can never fail or hold up the iteration, but work + // that is running is running: the iteration is not idle while it executes. + if (stages.stream().anyMatch(stage -> !stage.isAffectsIterationStatus() + && stage.getStatusCategory() == StatusCategory.RUNNING)) { + categories.add(StatusCategory.RUNNING); + } + var category = categories.isEmpty() ? derivedWhenNothingCounts(stages) : iterationCategory(categories); + iteration.setStatusCategory(category); + iteration.setStatus(applicable.stream() + .map(contributor -> contributor.iterationStatusLabel(category)) + .flatMap(java.util.Optional::stream) + .findFirst() + .orElseGet(category::defaultLabel)); + return iterationRepository.save(iteration); + } + + /** + * Refreshes the given iterations in a transaction of their own, so it also works after the change that + * caused it has been committed (for example: a test case was marked Ready, and the iteration that + * produced it has to recount its review progress). A failing iteration is logged and does not stop + * the others. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void refreshIterations(Collection iterationIds) { + for (var id : iterationIds) { + try { + iterationRepository.findByIdForUpdate(id).ifPresent(this::refresh); + } catch (RuntimeException e) { + log.warn("Could not refresh pipeline iteration {}", id, e); + } + } + } + + /** + * The worst category of the counting stages. Between two stages the iteration is under way, not idle: + * something has finished and the rest is still to come, so that is running; "pending" is only for an + * iteration in which nothing has started. + */ + private StatusCategory iterationCategory(java.util.Set categories) { + var worst = StatusCategory.aggregate(categories); + return worst == StatusCategory.PENDING && categories.contains(StatusCategory.PASSED) ? StatusCategory.RUNNING : worst; + } + + /** + * No stage counts (none reported yet, everything skipped, or only unknown statuses): an iteration + * without stages is pending, one whose stages are all unknown is unknown, anything else passed. + */ + private StatusCategory derivedWhenNothingCounts(List stages) { + if (stages.isEmpty()) { + return StatusCategory.PENDING; + } + var counting = stages.stream().filter(PipelineStage::isAffectsIterationStatus) + .map(PipelineStage::getStatusCategory).collect(Collectors.toSet()); + if (counting.contains(StatusCategory.UNKNOWN)) { + return StatusCategory.UNKNOWN; + } + return counting.isEmpty() ? StatusCategory.PENDING : StatusCategory.PASSED; + } + + private List append(List stages, PipelineStage stage) { + var all = new java.util.ArrayList<>(stages); + all.add(stage); + return all; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationService.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationService.java new file mode 100644 index 0000000000..c4768a6f69 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationService.java @@ -0,0 +1,44 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationPatchRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.util.OffsetRequest; +import org.springframework.data.domain.Page; + +public interface PipelineIterationService { + + /** + * Creates an iteration, with the stages known so far. With a non-null {@code idempotencyKey} the call + * is idempotent per pipeline: the same key with the same body answers with the iteration created the + * first time ({@code replayed = true}); the same key with a different body is a conflict. + */ + PipelineIterationIngestResult createPipelineIteration(Long projectId, Long userId, + PipelineIterationRQ rq, String idempotencyKey); + + default PipelineIterationDetailRS createPipelineIteration(Long projectId, Long userId, + PipelineIterationRQ rq) { + return createPipelineIteration(projectId, userId, rq, null).iteration(); + } + + /** + * Reports one stage of an existing iteration: creates it, or updates it in place. A stage that already + * finished and is reported again by a different CI job starts a new attempt; the finished one is kept. + */ + PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, String stageKey, PipelineStageRQ rq); + + PipelineIterationDetailRS patchPipelineIteration(Long projectId, Long iterationId, PipelineIterationPatchRQ rq); + + Page getPipelineIterations(Long projectId, Long pipelineId, + PipelineIterationFilter filter, OffsetRequest offsetRequest); + + PipelineIterationDetailRS getPipelineIteration(Long projectId, Long iterationId); + + /** + * Compares {@code candidateId} against {@code baselineId}; both must belong to the same pipeline. + */ + PipelineCompareRS comparePipelineIterations(Long projectId, Long candidateId, Long baselineId); +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImpl.java new file mode 100644 index 0000000000..2b7f2cf565 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImpl.java @@ -0,0 +1,532 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationPatchRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineWarningRS; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.event.PipelineIterationIngestedEvent; +import com.epam.reportportal.base.core.aifactory.mapper.PipelineMapper; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.dao.ProjectRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineSpecifications; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageAttemptRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.epam.reportportal.base.util.OffsetRequest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PipelineIterationServiceImpl implements PipelineIterationService { + + static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; + static final long MAX_REPORT_BYTES = 5L * 1024 * 1024; + + private final PipelineRepository pipelineRepository; + private final PipelineIterationRepository pipelineIterationRepository; + private final PipelineStageRepository pipelineStageRepository; + private final PipelineStageAttemptRepository pipelineStageAttemptRepository; + private final List relatedEntityResolvers; + private final List stageReportedHandlers; + private final ProjectRepository projectRepository; + private final PipelineMapper pipelineMapper; + private final PipelineViewAssembler viewAssembler; + private final PipelineIterationRefresher refresher; + private final PipelineComparator comparator; + private final ApplicationEventPublisher eventPublisher; + private final PipelineRawOutputValidator rawOutputValidator; + + @Override + @Transactional + public PipelineIterationIngestResult createPipelineIteration(Long projectId, Long userId, + PipelineIterationRQ rq, String idempotencyKey) { + validateSize(rq); + var run = rq.getIteration(); + var stageRQs = run.getStages() == null ? List.of() : run.getStages(); + validateStages(stageRQs); + + var pipeline = pipelineRepository + .findByProjectIdAndName(projectId, rq.getName()) + .orElseGet(() -> createPipeline(projectId, rq.getName(), rq.getType())); + // Serializes concurrent ingests of this pipeline: next iteration number, idempotency lookup. + var lockedPipeline = pipelineRepository.findByIdForUpdate(pipeline.getId()) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, "Pipeline '" + pipeline.getId() + "'")); + applyPipelineDescription(lockedPipeline, rq); + + String fingerprint = null; + if (idempotencyKey != null) { + validateIdempotencyKey(idempotencyKey); + fingerprint = IngestFingerprint.of(rq); + var previous = pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(lockedPipeline.getId(), + idempotencyKey); + if (previous.isPresent()) { + if (!fingerprint.equals(previous.get().getIdempotencyHash())) { + throw new ReportPortalException(ErrorType.IDEMPOTENCY_KEY_CONFLICT, + "Idempotency-Key '" + idempotencyKey + "' was already used with a different request body"); + } + return new PipelineIterationIngestResult(viewAssembler.detail(previous.get()), true); + } + } + + var iteration = new PipelineIteration(); + iteration.setPipeline(lockedPipeline); + iteration.setIterationNumber(pipelineIterationRepository + .findFirstByPipelineIdOrderByIterationNumberDesc(lockedPipeline.getId()) + .map(PipelineIteration::getIterationNumber) + .orElse(0) + 1); + iteration.setCreatedBy(userId); + if (run.getRerunOfIterationId() != null) { + var source = pipelineIterationRepository.findById(run.getRerunOfIterationId()) + .filter(it -> it.getPipeline().getId().equals(lockedPipeline.getId())) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Pipeline iteration '" + run.getRerunOfIterationId() + "' for pipeline '" + + lockedPipeline.getName() + "'")); + iteration.setRerun(true); + iteration.setRerunOfIterationId(source.getId()); + } + if (idempotencyKey != null) { + iteration.setIdempotencyKey(idempotencyKey); + iteration.setIdempotencyHash(fingerprint); + } + iteration.setStartedAt(run.getStartedAt()); + iteration.setFinishedAt(run.getFinishedAt()); + setAttributes(iteration, PipelineAttributeSupport.normalize(run.getAttributes())); + iteration.setStatus(StatusCategory.PENDING.defaultLabel()); + iteration.setStatusCategory(StatusCategory.PENDING); + var savedIteration = pipelineIterationRepository.save(iteration); + + var stages = new ArrayList(); + for (int i = 0; i < stageRQs.size(); i++) { + var stageRQ = stageRQs.get(i); + var stage = pipelineMapper.toStageEntity(stageRQ); + checkRawOutput(stage); + stage.setIteration(savedIteration); + stage.setAttempt(1); + if (stage.getSequence() == null) { + stage.setSequence(i + 1); + } + stages.add(stage); + } + var savedStages = pipelineStageRepository.saveAll(stages); + assignStagePaths(stageRQs, savedStages); + pipelineStageRepository.saveAll(savedStages); + + var warnings = new ArrayList(); + for (int i = 0; i < stageRQs.size(); i++) { + var stage = savedStages.get(i); + warnings.addAll(resolveRelatedEntities(projectId, lockedPipeline, stage, false)); + notifyStageReportedHandlers(projectId, lockedPipeline, stage, stage.getStatusCategory() == StatusCategory.PASSED); + } + + var refreshed = refresher.refresh(savedIteration); + eventPublisher.publishEvent( + new PipelineIterationIngestedEvent(refreshed.getId(), lockedPipeline.getId(), projectId)); + + var detail = viewAssembler.detail(refreshed); + if (!warnings.isEmpty()) { + detail.setWarnings(warnings); + } + return new PipelineIterationIngestResult(detail, false); + } + + @Override + @Transactional + public PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, String stageKey, PipelineStageRQ rq) { + validateSize(rq); + if (rq.getStageKey() != null && !rq.getStageKey().equals(stageKey)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "'stageKey' in the body does not match the stage key in the path"); + } + var iteration = lockIterationOrThrow(projectId, iterationId); + var existing = pipelineStageRepository.findByIterationIdAndStageKey(iterationId, stageKey).orElse(null); + + PipelineStage stage; + boolean becamePassed; + boolean hadRelatedEntities = false; + if (existing == null) { + rq.setStageKey(stageKey); + stage = pipelineMapper.toStageEntity(rq); + checkRawOutput(stage); + stage.setIteration(iteration); + stage.setAttempt(1); + if (stage.getSequence() == null) { + stage.setSequence(pipelineStageRepository.findByIterationIdOrderBySequenceAsc(iterationId).stream() + .map(PipelineStage::getSequence).filter(Objects::nonNull).max(Integer::compare).orElse(0) + 1); + } + var parent = resolveParent(iterationId, rq.getParentStageKey(), null); + stage.setParentStageId(parent == null ? null : parent.getId()); + stage = pipelineStageRepository.save(stage); + stage.setPath(parent == null ? String.valueOf(stage.getId()) : parent.getPath() + "." + stage.getId()); + stage = pipelineStageRepository.save(stage); + becamePassed = stage.getStatusCategory() == StatusCategory.PASSED; + } else { + stage = existing; + if (stage.getOwnedBy() == StageOwner.REPORTPORTAL) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Stage '" + stageKey + "' is maintained by ReportPortal and cannot be reported"); + } + if (rq.getParentStageKey() != null) { + resolveParent(iterationId, rq.getParentStageKey(), stage); + } + hadRelatedEntities = stage.getRelatedEntities() != null && !stage.getRelatedEntities().getItems().isEmpty(); + var previousCategory = stage.getStatusCategory(); + if (startsNewAttempt(stage, rq)) { + pipelineStageAttemptRepository.save(PipelineStageAttempt.snapshotOf(stage)); + stage.setAttempt(stage.getAttempt() + 1); + previousCategory = StatusCategory.PENDING; + } + pipelineMapper.applyStage(stage, rq); + checkRawOutput(stage); + stage = pipelineStageRepository.save(stage); + becamePassed = previousCategory != StatusCategory.PASSED && stage.getStatusCategory() == StatusCategory.PASSED; + } + + var warnings = resolveRelatedEntities(projectId, iteration.getPipeline(), stage, hadRelatedEntities); + notifyStageReportedHandlers(projectId, iteration.getPipeline(), stage, becamePassed); + + var refreshed = refresher.refresh(iteration); + var detail = viewAssembler.detail(refreshed); + if (!warnings.isEmpty()) { + detail.setWarnings(warnings); + } + return detail; + } + + @Override + @Transactional + public PipelineIterationDetailRS patchPipelineIteration(Long projectId, Long iterationId, + PipelineIterationPatchRQ rq) { + var iteration = lockIterationOrThrow(projectId, iterationId); + if (rq.getAttributes() != null || rq.getRemoveAttributes() != null) { + setAttributes(iteration, + PipelineAttributeSupport.merge(iteration.getAttributes(), rq.getAttributes(), rq.getRemoveAttributes())); + } + if (rq.getStartedAt() != null) { + iteration.setStartedAt(rq.getStartedAt()); + } + if (rq.getFinishedAt() != null) { + iteration.setFinishedAt(rq.getFinishedAt()); + } + return viewAssembler.detail(refresher.refresh(iteration)); + } + + @Override + @Transactional(readOnly = true) + public Page getPipelineIterations(Long projectId, Long pipelineId, + PipelineIterationFilter filter, OffsetRequest offsetRequest) { + var pipeline = getPipelineOrThrow(projectId, pipelineId); + var page = pipelineIterationRepository.findAll( + PipelineSpecifications.iterationsOfPipeline(pipeline.getId(), filter.search(), filter.statuses(), + filter.attributes()), + PipelinePaging.capLimit(offsetRequest, Sort.by(Sort.Order.desc("iterationNumber")))); + return new PageImpl<>(viewAssembler.summaries(page.getContent()), page.getPageable(), page.getTotalElements()); + } + + @Override + @Transactional(readOnly = true) + public PipelineIterationDetailRS getPipelineIteration(Long projectId, Long iterationId) { + return viewAssembler.detail(getIterationOrThrow(projectId, iterationId)); + } + + @Override + @Transactional(readOnly = true) + public PipelineCompareRS comparePipelineIterations(Long projectId, Long candidateId, Long baselineId) { + var candidate = getIterationOrThrow(projectId, candidateId); + var baseline = getIterationOrThrow(projectId, baselineId); + if (!candidate.getPipeline().getId().equals(baseline.getPipeline().getId())) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Iterations must belong to the same pipeline definition to be compared"); + } + return comparator.compare(viewAssembler.detail(baseline), viewAssembler.detail(candidate)); + } + + private void validateIdempotencyKey(String idempotencyKey) { + if (idempotencyKey.isBlank() || idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "'Idempotency-Key' must be 1-" + MAX_IDEMPOTENCY_KEY_LENGTH + " characters"); + } + } + + private void validateSize(Object report) { + if (IngestFingerprint.sizeInBytes(report) > MAX_REPORT_BYTES) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "A report must not exceed 5 MB"); + } + } + + /** + * Stage keys must be present and unique, so the rest of the report can refer to stages by key. + */ + private void validateStages(List stageRQs) { + var keys = new HashSet(); + for (var stageRQ : stageRQs) { + if (stageRQ.getStageKey() == null || stageRQ.getStageKey().isBlank()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "'stageKey' is required for every stage"); + } + if (!keys.add(stageRQ.getStageKey())) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Duplicate stageKey '" + stageRQ.getStageKey() + "' in ingest payload"); + } + } + } + + /** + * A reported raw output that does not match the schema it names is rejected: it is not kept, and the + * stage is marked failed with the reason. + */ + private void checkRawOutput(PipelineStage stage) { + var rawOutput = stage.getRawOutput() == null ? null : stage.getRawOutput().getContent(); + rawOutputValidator.validate(stage.getRawOutputSchemaName(), rawOutput).ifPresent(reason -> { + stage.setRawOutput(null); + stage.setStatus("Failed"); + stage.setStatusCategory(StatusCategory.FAILED); + var attributes = new ArrayList( + stage.getAttributes() == null ? List.of() : stage.getAttributes().getItems()); + attributes.removeIf(attribute -> "failureReason".equals(attribute.getKey())); + attributes.add(PipelineAttribute.builder().key("failureReason").label("Failure reason").value(reason) + .type(AttributeType.META).valueType(AttributeValueType.TEXT).build()); + stage.setAttributes(new PipelineAttributes(attributes)); + }); + } + + private Pipeline createPipeline(Long projectId, String name, String type) { + var pipeline = new Pipeline(); + pipeline.setProject(projectRepository.getReferenceById(projectId)); + pipeline.setName(name); + pipeline.setType(type); + return pipelineRepository.save(pipeline); + } + + /** + * Records the pipeline's type the first time it is reported and merges its attributes; a different + * type than the recorded one is rejected. + */ + private void applyPipelineDescription(Pipeline pipeline, PipelineIterationRQ rq) { + var reportedType = rq.getType(); + if (reportedType != null && !reportedType.isBlank()) { + if (pipeline.getType() == null) { + pipeline.setType(reportedType); + } else if (!pipeline.getType().equals(reportedType)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Pipeline '" + pipeline.getName() + "' is of type '" + pipeline.getType() + "', not '" + reportedType + "'"); + } + } + if (rq.getAttributes() != null && !rq.getAttributes().isEmpty()) { + pipeline.setAttributes(PipelineAttributeSupport.toColumn( + PipelineAttributeSupport.merge(pipeline.getAttributes(), rq.getAttributes(), null))); + } + pipelineRepository.save(pipeline); + } + + private void setAttributes(PipelineIteration iteration, List attributes) { + iteration.setAttributes(PipelineAttributeSupport.toColumn(attributes)); + iteration.setSearchText(PipelineAttributeSupport.searchText(attributes)); + iteration.setAttributeIndex(PipelineAttributeSupport.attributeIndex(attributes)); + } + + private Pipeline getPipelineOrThrow(Long projectId, Long pipelineId) { + return pipelineRepository.findById(pipelineId) + .filter(p -> p.getProject().getId().equals(projectId)) + .orElseThrow( + () -> new ReportPortalException(ErrorType.NOT_FOUND, "Pipeline '" + pipelineId + "'")); + } + + private PipelineIteration getIterationOrThrow(Long projectId, Long iterationId) { + return pipelineIterationRepository.findById(iterationId) + .filter(it -> it.getPipeline().getProject().getId().equals(projectId)) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Pipeline iteration '" + iterationId + "'")); + } + + private PipelineIteration lockIterationOrThrow(Long projectId, Long iterationId) { + return pipelineIterationRepository.findByIdForUpdate(iterationId) + .filter(it -> it.getPipeline().getProject().getId().equals(projectId)) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Pipeline iteration '" + iterationId + "'")); + } + + /** + * A finished stage reported again by a different run is a new attempt; reports from the same run, + * or reports of a stage that has not finished, update the current attempt. + */ + private boolean startsNewAttempt(PipelineStage stage, PipelineStageRQ rq) { + var finished = switch (stage.getStatusCategory()) { + case PASSED, FAILED, SKIPPED -> true; + default -> false; + }; + var reportedRun = rq.getExecution() == null ? null : rq.getExecution().getExternalRunId(); + var currentRun = stage.getExecution() == null || stage.getExecution().getContent() == null + ? null : stage.getExecution().getContent().getExternalRunId(); + return finished && reportedRun != null && !reportedRun.equals(currentRun); + } + + /** + * The parent a stage is nested in, looked up by key among the iteration's stages. A stage that already + * exists cannot be moved to another parent. + */ + private PipelineStage resolveParent(Long iterationId, String parentStageKey, PipelineStage stage) { + if (parentStageKey == null) { + return null; + } + var parent = pipelineStageRepository.findByIterationIdAndStageKey(iterationId, parentStageKey) + .orElseThrow(() -> new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "'parentStageKey' \"" + parentStageKey + "\" does not reference a stage of this iteration")); + if (stage != null && !Objects.equals(stage.getParentStageId(), parent.getId())) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Stage '" + stage.getStageKey() + "' is already reported under a different parent"); + } + return parent; + } + + /** + * Hands the entities the stage's result tables point to (the {@code target} of their cells) to the resolver + * of their type, which replaces what the stage was related to before. Entities a resolver cannot find come + * back as warnings; the report itself is not rejected. Entities of a type no resolver handles need no + * handling: they stay in the table as reported. + *

+ * Example: an Upload report has a row whose cell target is {@code {entityType: "TEST_CASE", entityId: "TC-12"}}; + * the resolver for {@code TEST_CASE} links the stage to test case TC-12. + */ + private List resolveRelatedEntities(Long projectId, Pipeline pipeline, PipelineStage stage, + boolean hadRelatedEntities) { + var relatedEntities = stage.getRelatedEntities() == null ? List.of() + : stage.getRelatedEntities().getItems(); + if (relatedEntities.isEmpty() && !hadRelatedEntities) { + return List.of(); + } + var entityIdsByType = new LinkedHashMap>(); + relatedEntities.forEach(entity -> entityIdsByType + .computeIfAbsent(entity.getEntityType(), type -> new ArrayList<>()).add(entity.getEntityId())); + + var warnings = new ArrayList(); + for (var resolver : relatedEntityResolvers) { + var wanted = entityIdsByType.get(resolver.entityType()); + var notFound = resolver.resolve(projectId, pipeline, stage, + wanted == null ? List.of() : wanted.stream().distinct().toList()); + if (!notFound.isEmpty()) { + warnings.add(PipelineWarningRS.builder() + .code(PipelineWarningRS.RELATED_ENTITY_NOT_FOUND) + .message("Related entities not found; they were not linked") + .stageId(stage.getId()) + .entityType(resolver.entityType()) + .entityIds(notFound) + .build()); + } + } + return warnings; + } + + /** + * Lets the behaviour plugged into the pipeline react to the saved stage and adds the attributes it answers + * with to the stage, replacing ones with the same key. + *

+ * Example: the Auto-Ready handler answers {@code autoReadyPromoted = 2} after the Upload stage passed. + */ + private void notifyStageReportedHandlers(Long projectId, Pipeline pipeline, PipelineStage stage, + boolean stagePassed) { + var report = new PipelineStageReportedHandler.StageReport(projectId, pipeline, stage, stagePassed); + var added = new ArrayList(); + stageReportedHandlers.forEach(handler -> added.addAll(handler.onStageReported(report))); + if (added.isEmpty()) { + return; + } + var merged = PipelineAttributeSupport.merge(stage.getAttributes(), added, null); + stage.setAttributes(PipelineAttributeSupport.toColumn(merged)); + pipelineStageRepository.save(stage); + } + + /** + * Resolves each stage's {@code parentStageKey} (a payload-local reference - child stage ids + * don't exist until after the first {@code saveAll}) into a real {@code parentStageId}, and + * builds {@code path} the same way {@code StartTestItemHandlerImpl} builds + * {@code TestItem.path}: a root stage's path is its own id, a nested stage's path is its + * parent's path + "." + its own id. + */ + private void assignStagePaths(List stageRQs, List savedStages) { + // Keyed off the RQ list (not the saved entity's own stageKey) and paired positionally with + // savedStages, same positional pairing the ref resolution uses - the RQ payload is the only + // place 'parentStageKey' references are declared against. + var stageByKey = new HashMap(); + var parentKeyByKey = new HashMap(); + for (int i = 0; i < stageRQs.size(); i++) { + var key = stageRQs.get(i).getStageKey(); + stageByKey.put(key, savedStages.get(i)); + if (stageRQs.get(i).getParentStageKey() != null) { + parentKeyByKey.put(key, stageRQs.get(i).getParentStageKey()); + } + } + + var pathByKey = new HashMap(); + for (var key : stageByKey.keySet()) { + computeStagePath(key, stageByKey, parentKeyByKey, pathByKey, new LinkedHashSet<>()); + } + + for (int i = 0; i < stageRQs.size(); i++) { + var key = stageRQs.get(i).getStageKey(); + var stage = savedStages.get(i); + stage.setPath(pathByKey.get(key)); + var parentKey = parentKeyByKey.get(key); + if (parentKey != null) { + stage.setParentStageId(stageByKey.get(parentKey).getId()); + } + } + } + + private String computeStagePath(String key, Map stageByKey, + Map parentKeyByKey, Map pathByKey, Set visiting) { + var cached = pathByKey.get(key); + if (cached != null) { + return cached; + } + if (!visiting.add(key)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Cycle detected in stage 'parentStageKey' chain at '" + key + "'"); + } + + var parentKey = parentKeyByKey.get(key); + String path; + if (parentKey == null) { + path = String.valueOf(stageByKey.get(key).getId()); + } else { + var parentStage = stageByKey.get(parentKey); + if (parentStage == null) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "'parentStageKey' \"" + parentKey + "\" does not reference a stage in this ingest payload"); + } + var parentPath = computeStagePath(parentKey, stageByKey, parentKeyByKey, pathByKey, visiting); + path = parentPath + "." + stageByKey.get(key).getId(); + } + pathByKey.put(key, path); + return path; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelinePaging.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelinePaging.java new file mode 100644 index 0000000000..167119fe74 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelinePaging.java @@ -0,0 +1,31 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.util.OffsetRequest; +import org.springframework.data.domain.Sort; + +/** + * Paging guard shared by the pipeline list endpoints: {@code OffsetArgumentResolver} accepts any + * {@code limit}, so a caller could otherwise request an unbounded page. + */ +final class PipelinePaging { + + static final int MAX_LIMIT = 200; + + private PipelinePaging() { + } + + /** + * Caps the limit and fixes the ordering: the list endpoints have a defined order the caller + * cannot change. + */ + static OffsetRequest capLimit(OffsetRequest request, Sort sort) { + return new OffsetRequest(request.getOffset(), Math.min(request.getPageSize(), MAX_LIMIT), sort); + } + + static OffsetRequest capLimit(OffsetRequest request) { + if (request.getPageSize() <= MAX_LIMIT) { + return request; + } + return new OffsetRequest(request.getOffset(), MAX_LIMIT, request.getSort()); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidator.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidator.java new file mode 100644 index 0000000000..8d486e5fd3 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidator.java @@ -0,0 +1,53 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.util.JsonSchemaValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.ValidationMessage; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +/** + * Checks the raw output a stage reports against the schema it names. Schemas live on the classpath as + * {@code schema/pipeline/.schema.json}; a reference without a schema is not checked, so + * pipelines can attach raw outputs of their own. + */ +@Component +@RequiredArgsConstructor +public class PipelineRawOutputValidator { + + private static final String SCHEMA_BASE = "https://schema.reportportal.io/pipeline/"; + private static final String RESOURCE_BASE = "schema/pipeline/"; + private static final String SUFFIX = ".schema.json"; + private static final Pattern SAFE_REF = Pattern.compile("[A-Za-z0-9._-]{1,128}"); + static final int MAX_REASON_LENGTH = 1000; + + private final JsonSchemaValidator schemaValidator; + private final ObjectMapper objectMapper; + + /** + * @return why the raw output is not valid, or empty when it is valid or there is no schema to check it with + */ + public Optional validate(String rawOutputSchemaName, Map rawOutput) { + if (rawOutputSchemaName == null || rawOutput == null || !SAFE_REF.matcher(rawOutputSchemaName).matches() + || !new ClassPathResource(RESOURCE_BASE + rawOutputSchemaName + SUFFIX).exists()) { + return Optional.empty(); + } + try { + var messages = schemaValidator.validate(SCHEMA_BASE + rawOutputSchemaName + SUFFIX, objectMapper.valueToTree(rawOutput)); + if (messages.isEmpty()) { + return Optional.empty(); + } + var reason = "Raw output does not match schema '" + rawOutputSchemaName + "': " + + messages.stream().map(ValidationMessage::getMessage).sorted().collect(Collectors.joining("; ")); + return Optional.of(reason.length() > MAX_REASON_LENGTH ? reason.substring(0, MAX_REASON_LENGTH) : reason); + } catch (IOException e) { + throw new IllegalStateException("Could not read schema '" + rawOutputSchemaName + "'", e); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRelatedEntityResolver.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRelatedEntityResolver.java new file mode 100644 index 0000000000..a2f9eecfd2 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRelatedEntityResolver.java @@ -0,0 +1,33 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import java.util.List; + +/** + * Extension point for the things elsewhere in ReportPortal that a stage reports it worked on or produced. + *

+ * Example: a test case generation stage has a row cell with target {@code {entityType: "TEST_CASE", entityId: "TC-12"}}. The resolver whose + * {@link #entityType()} is {@code TEST_CASE} (see {@code TestCaseRelatedEntityResolver}) finds test case + * TC-12 and links it to the stage. A plain CI/CD pipeline reports no related entities, so no resolver is + * involved. + *

+ * Each resolver owns one entity type: it knows how to find the entities, what to keep about the link and + * what else follows from it. The pipeline core never knows which types exist. + */ +public interface PipelineRelatedEntityResolver { + + /** + * The kind of entity this resolver handles, e.g. {@code TEST_CASE}. + */ + String entityType(); + + /** + * Replaces what the stage is related to, among entities of this type, with {@code entityIds} + * (possibly none). + * + * @param entityIds ids as the pipeline reported them, e.g. {@code ["TC-12", "TC-13"]} + * @return the ids that could not be found, e.g. {@code ["TC-13"]}; they are reported back as warnings + */ + List resolve(Long projectId, Pipeline pipeline, PipelineStage stage, List entityIds); +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineService.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineService.java new file mode 100644 index 0000000000..c3276c0361 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineService.java @@ -0,0 +1,21 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineSettingsRQ; +import com.epam.reportportal.base.util.OffsetRequest; +import org.springframework.data.domain.Page; + +public interface PipelineService { + + Page getPipelines(Long projectId, String search, OffsetRequest offsetRequest); + + /** + * Changes the settings of a pipeline definition. Each setting named in the request replaces the stored one, + * a setting sent as {@code null} is removed, the others stay. The result is checked by every + * {@link PipelineSettingsValidator} before it is saved. + *

+ * Example: {@code {"autoReady": {"enabled": true, "threshold": 90}}} turns on Auto-Ready (validated by the + * test case Auto-Ready validator, which requires a threshold between 0 and 100). + */ + PipelineRS patchPipelineSettings(Long projectId, Long pipelineId, PipelineSettingsRQ rq); +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImpl.java new file mode 100644 index 0000000000..e03566e000 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImpl.java @@ -0,0 +1,88 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineSettingsRQ; +import com.epam.reportportal.base.core.aifactory.mapper.PipelineMapper; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineSpecifications; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineSettings; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.epam.reportportal.base.util.OffsetRequest; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PipelineServiceImpl implements PipelineService { + + private final PipelineRepository pipelineRepository; + private final PipelineIterationRepository pipelineIterationRepository; + private final PipelineMapper pipelineMapper; + private final List settingsValidators; + + @Override + @Transactional(readOnly = true) + public Page getPipelines(Long projectId, String search, OffsetRequest offsetRequest) { + var page = pipelineRepository.findAll(PipelineSpecifications.pipelinesOfProject(projectId, search), + PipelinePaging.capLimit(offsetRequest, Sort.by("name", "id"))); + var pipelineIds = page + .getContent() + .stream() + .map(Pipeline::getId) + .toList(); + if (pipelineIds.isEmpty()) { + return page.map(pipeline -> pipelineMapper.toPipelineRS(pipeline, 0)); + } + + var iterationsCountByPipelineId = pipelineIterationRepository + .countByPipelineIdIn(pipelineIds) + .stream() + .collect(Collectors.toMap( + PipelineIterationRepository.PipelineIterationCount::getPipelineId, + PipelineIterationRepository.PipelineIterationCount::getCount) + ); + return page.map(pipeline -> pipelineMapper.toPipelineRS(pipeline, + iterationsCountByPipelineId.getOrDefault(pipeline.getId(), 0L))); + } + + @Override + @Transactional + public PipelineRS patchPipelineSettings(Long projectId, Long pipelineId, PipelineSettingsRQ rq) { + var pipeline = pipelineRepository + .findById(pipelineId) + .filter(p -> p.getProject().getId().equals(projectId)) + .orElseThrow( + () -> new ReportPortalException(ErrorType.NOT_FOUND, "Pipeline '" + pipelineId + "'")); + + var settings = new LinkedHashMap( + pipeline.getSettings() == null ? Map.of() : pipeline.getSettings().getValues()); + rq.getSettings().forEach((name, value) -> { + if (value == null) { + settings.remove(name); + } else { + settings.put(name, value); + } + }); + settingsValidators.forEach(validator -> validator.validate(pipeline, settings)); + + pipeline.setSettings(settings.isEmpty() ? null : new PipelineSettings(settings)); + return toPipelineRS(pipelineRepository.save(pipeline)); + } + + private PipelineRS toPipelineRS(Pipeline pipeline) { + var iterationsCount = pipelineIterationRepository.countByPipelineId(pipeline.getId()); + return pipelineMapper.toPipelineRS(pipeline, iterationsCount); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineSettingsValidator.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineSettingsValidator.java new file mode 100644 index 0000000000..46bb4335ac --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineSettingsValidator.java @@ -0,0 +1,26 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import java.util.Map; + +/** + * Extension point that checks the settings of a pipeline before they are saved. + *

+ * Example: the test case Auto-Ready validator (see {@code AutoReadySettingsValidator}) rejects + * {@code {"autoReady": {"enabled": true}}} because a threshold between 0 and 100 is missing, and ignores + * every setting that is not its own. + *

+ * The pipeline core stores settings without interpreting them; validators are how the behaviour that reads + * a setting keeps its values sane. + */ +public interface PipelineSettingsValidator { + + /** + * @param pipeline the pipeline whose settings change + * @param settings all settings the pipeline will have after the change, e.g. + * {@code {"autoReady": {"enabled": true, "threshold": 90}}} + * @throws com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException with a bad request + * error when a setting this validator owns has an unacceptable value + */ + void validate(Pipeline pipeline, Map settings); +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageContributor.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageContributor.java new file mode 100644 index 0000000000..410e98883e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageContributor.java @@ -0,0 +1,44 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import java.util.List; +import java.util.Optional; + +/** + * Extension point for stages ReportPortal itself owns, e.g. a human review that is not a CI job but is + * derived from the lifecycle of entities the iteration produced. The refresher asks every contributor + * that supports a pipeline to bring its stages up to date whenever the iteration is reported into or + * something it depends on changes. + */ +public interface PipelineStageContributor { + + boolean supports(Pipeline pipeline); + + /** + * Creates or updates the stages this contributor owns for the iteration. + * + * @param stages all current stages of the iteration, ordered by sequence + * @return the stages to save: new ones (already bound to the iteration) and changed existing ones + */ + List contribute(PipelineIteration iteration, List stages); + + /** + * Wording for the derived iteration status when the pipeline kind has its own; the category's default + * label is used when empty. + */ + default Optional iterationStatusLabel(StatusCategory category) { + return Optional.empty(); + } + + /** + * Facts about the whole iteration that follow from the stages this contributor owns (e.g. how many of + * its test cases are Ready); the refresher adds them to the iteration's attributes by key. + */ + default List iterationAttributes(PipelineIteration iteration, List stages) { + return List.of(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageReportedHandler.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageReportedHandler.java new file mode 100644 index 0000000000..3fc3ad8194 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageReportedHandler.java @@ -0,0 +1,37 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import java.util.List; + +/** + * Extension point that reacts to a stage report once it is saved, for behaviour that belongs to one kind of + * pipeline and not to the core. + *

+ * Example: when the Upload stage of a test case generation pipeline passes and Auto-Ready is on for the + * pipeline, the Auto-Ready handler (see {@code AutoReadyOnStagePassed}) promotes the stage's test cases and + * answers with the attribute {@code autoReadyPromoted = 1}, which the core adds to the stage and which sums up + * into the iteration's totals. A plain CI/CD pipeline has no such handler, so nothing happens. + */ +public interface PipelineStageReportedHandler { + + /** + * Called after each stage report, with the stage already saved and its related entities resolved. + * + * @return attributes to add to the stage, replacing ones with the same key, or an empty list + */ + List onStageReported(StageReport report); + + /** + * What a handler is told about a stage report. + * + * @param projectId the project the pipeline belongs to + * @param pipeline the pipeline the stage belongs to, including its settings + * @param stage the stage as saved after this report + * @param stagePassed whether this report is the one that made the stage pass, so a handler can act once + * and not on every repeated report of an already passed stage + */ + record StageReport(Long projectId, Pipeline pipeline, PipelineStage stage, boolean stagePassed) { + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryService.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryService.java new file mode 100644 index 0000000000..5be0ad2319 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryService.java @@ -0,0 +1,9 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRS; + +public interface PipelineStageRetryService { + + PipelineStageRetryRS retryPipelineIterationStage(Long projectId, Long userId, Long iterationId, Long stageId, PipelineStageRetryRQ rq); +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImpl.java new file mode 100644 index 0000000000..736149f397 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImpl.java @@ -0,0 +1,181 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.connector.CiTriggerConnector; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRS; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.event.PipelineStageRetryRequestedEvent; +import com.epam.reportportal.base.infrastructure.persistence.dao.IntegrationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageAttemptRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import com.epam.reportportal.base.infrastructure.persistence.entity.enums.IntegrationGroupEnum; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +@Service +public class PipelineStageRetryServiceImpl implements PipelineStageRetryService { + + static final String PENDING_STATUS = "Pending"; + + private final PipelineStageRepository pipelineStageRepository; + private final PipelineStageAttemptRepository pipelineStageAttemptRepository; + private final PipelineIterationRefresher iterationRefresher; + private final IntegrationRepository integrationRepository; + private final ApplicationEventPublisher eventPublisher; + private final Map connectorsByType; + private final TransactionTemplate transactionTemplate; + + public PipelineStageRetryServiceImpl(PipelineStageRepository pipelineStageRepository, + PipelineStageAttemptRepository pipelineStageAttemptRepository, + PipelineIterationRefresher iterationRefresher, + IntegrationRepository integrationRepository, + ApplicationEventPublisher eventPublisher, + List connectors, + PlatformTransactionManager transactionManager) { + this.pipelineStageRepository = pipelineStageRepository; + this.pipelineStageAttemptRepository = pipelineStageAttemptRepository; + this.iterationRefresher = iterationRefresher; + this.integrationRepository = integrationRepository; + this.eventPublisher = eventPublisher; + this.connectorsByType = connectors + .stream() + .collect(Collectors.toMap(CiTriggerConnector::getSupportedExecutorType, Function.identity())); + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** + * Deliberately not {@code @Transactional}: the external CI call in the middle must not run + * inside a DB transaction (it can take up to the connector's response timeout, and holding a + * connection/transaction open for a slow third-party call risks exhausting the pool and leaves + * an inconsistent state if the later save fails after the trigger already succeeded). The DB + * steps each get their own short, explicit transaction instead: the stage is claimed (locked, + * validated and flipped to pending) before the CI call so a concurrent retry sees it in flight, + * and the claim is reverted if the CI call fails. + */ + @Override + public PipelineStageRetryRS retryPipelineIterationStage(Long projectId, Long userId, Long iterationId, Long stageId, PipelineStageRetryRQ rq) { + var resolved = transactionTemplate.execute(status -> claimStageForRetry(projectId, iterationId, stageId)); + + String triggeredRunUrl; + try { + triggeredRunUrl = resolved.connector().triggerStageRerun(resolved.integration(), resolved.stage()); + } catch (RuntimeException e) { + transactionTemplate.executeWithoutResult( + status -> releaseClaim(stageId, iterationId, projectId, resolved.previousStatus(), + resolved.previousCategory())); + throw e; + } + + var comment = rq == null ? null : rq.getComment(); + return transactionTemplate.execute( + status -> persistRetryResult(stageId, iterationId, projectId, userId, comment, triggeredRunUrl, + resolved.finishedAttempt())); + } + + private ResolvedStageForRetry claimStageForRetry(Long projectId, Long iterationId, Long stageId) { + var stage = findStageOrThrow(projectId, iterationId, stageId, true); + + var category = stage.getStatusCategory(); + if (category == StatusCategory.PENDING || category == StatusCategory.RUNNING) { + throw new ReportPortalException(ErrorType.PIPELINE_STAGE_RETRY_CONFLICT, + "Stage '" + stageId + "' is already running or being retried"); + } + if (category == StatusCategory.PASSED || category == StatusCategory.SKIPPED) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Stage '" + stageId + "' has passed and cannot be retried"); + } + var execution = stage.getExecution() == null ? null : stage.getExecution().getContent(); + if (category == StatusCategory.UNKNOWN || stage.getOwnedBy() == StageOwner.REPORTPORTAL + || execution == null || !execution.isRetryable()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Stage '" + stageId + "' is not retryable"); + } + + var connector = connectorsByType.get(execution.getExecutorType()); + if (connector == null) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Nothing can run a stage of execution type '" + execution.getExecutorType() + "' again"); + } + + var project = stage.getIteration().getPipeline().getProject(); + var integration = resolveIntegration(project, connector); + + var previousStatus = stage.getStatus(); + var finishedAttempt = PipelineStageAttempt.snapshotOf(stage); + setStageStatus(stage, PENDING_STATUS, StatusCategory.PENDING); + return new ResolvedStageForRetry(stage, integration, connector, previousStatus, category, finishedAttempt); + } + + private void releaseClaim(Long stageId, Long iterationId, Long projectId, String previousStatus, + StatusCategory previousCategory) { + setStageStatus(findStageOrThrow(projectId, iterationId, stageId, true), previousStatus, previousCategory); + } + + /** + * Saves the stage's new status and keeps the iteration's derived status in step with it. + */ + private void setStageStatus(PipelineStage stage, String status, StatusCategory category) { + stage.setStatus(status); + stage.setStatusCategory(category); + pipelineStageRepository.save(stage); + iterationRefresher.refresh(stage.getIteration()); + } + + private PipelineStageRetryRS persistRetryResult(Long stageId, Long iterationId, Long projectId, Long userId, + String comment, String triggeredRunUrl, PipelineStageAttempt finishedAttempt) { + var stage = findStageOrThrow(projectId, iterationId, stageId, false); + + // the attempt that just ended goes to history; the stage starts a fresh one + pipelineStageAttemptRepository.save(finishedAttempt); + stage.setAttempt(finishedAttempt.getAttempt() + 1); + stage.setStartedAt(null); + stage.setFinishedAt(null); + stage.setLastRetriedAt(Instant.now()); + stage.setLastRetriedBy(userId); + pipelineStageRepository.save(stage); + + eventPublisher.publishEvent(new PipelineStageRetryRequestedEvent(stage.getId(), iterationId, projectId, userId, comment)); + + return PipelineStageRetryRS.builder() + .stageId(stage.getId()) + .status(stage.getStatus()) + .statusCategory(stage.getStatusCategory()) + .triggeredRunUrl(triggeredRunUrl) + .build(); + } + + private PipelineStage findStageOrThrow(Long projectId, Long iterationId, Long stageId, boolean forUpdate) { + return (forUpdate ? pipelineStageRepository.findByIdForUpdate(stageId) : pipelineStageRepository.findById(stageId)) + .filter(s -> s.getIteration().getId().equals(iterationId)) + .filter(s -> s.getIteration().getPipeline().getProject().getId().equals(projectId)) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, "Pipeline stage '" + stageId + "'")); + } + + private Integration resolveIntegration(Project project, CiTriggerConnector connector) { + var typeName = connector.getIntegrationTypeName(); + return integrationRepository.findAllProjectByGroup(project, IntegrationGroupEnum.AUTOMATION).stream() + .filter(i -> i.isEnabled() && i.getType() != null && typeName.equalsIgnoreCase(i.getType().getName())) + .findFirst() + .orElseThrow(() -> new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "No enabled '" + typeName + "' automation integration configured for this project")); + } + + private record ResolvedStageForRetry(PipelineStage stage, Integration integration, CiTriggerConnector connector, + String previousStatus, StatusCategory previousCategory, + PipelineStageAttempt finishedAttempt) {} +} diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineViewAssembler.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineViewAssembler.java new file mode 100644 index 0000000000..c16e53e420 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineViewAssembler.java @@ -0,0 +1,129 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRefRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineUserRS; +import com.epam.reportportal.base.core.aifactory.mapper.PipelineMapper; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.dao.UserRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageAttemptRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Builds iteration views (summary for lists, detail for a page) from the stored entities, loading what + * they need - stages, attempts, the users who started iterations, the iterations re-runs point to - in + * batches. Shared by the pipeline and iteration services so a list and a page always agree. + */ +@Component +@RequiredArgsConstructor +public class PipelineViewAssembler { + + private final PipelineStageRepository stageRepository; + private final PipelineStageAttemptRepository attemptRepository; + private final PipelineIterationRepository iterationRepository; + private final UserRepository userRepository; + private final PipelineMapper mapper; + + /** + * Summaries in the order of {@code iterations}. + */ + public List summaries(List iterations) { + if (iterations.isEmpty()) { + return List.of(); + } + var iterationIds = iterations.stream().map(PipelineIteration::getId).toList(); + var stagesByIteration = stageRepository.findByIterationIdInOrderByIterationIdAscSequenceAsc(iterationIds) + .stream() + .collect(Collectors.groupingBy(stage -> stage.getIteration().getId())); + var users = users(iterations); + var refs = rerunSources(iterations); + return iterations.stream() + .map(iteration -> { + var stages = stagesByIteration.getOrDefault(iteration.getId(), List.of()); + return mapper.toIterationSummaryRS(iteration, effectiveAttributes(iteration, stages), + stages.stream().map(mapper::toStageSummaryRS).collect(Collectors.toList()), + lookup(users, iteration.getCreatedBy()), lookup(refs, iteration.getRerunOfIterationId())); + }) + .toList(); + } + + public PipelineIterationSummaryRS summary(PipelineIteration iteration) { + return summaries(List.of(iteration)).get(0); + } + + public PipelineIterationDetailRS detail(PipelineIteration iteration) { + var stages = stageRepository.findByIterationIdOrderBySequenceAsc(iteration.getId()); + var attemptsByStageKey = attemptRepository.findByIterationIdOrderByStageKeyAscAttemptAsc(iteration.getId()) + .stream() + .collect(Collectors.groupingBy(PipelineStageAttempt::getStageKey)); + var stageRS = new ArrayList(); + for (var stage : stages) { + var rs = mapper.toStageRS(stage); + var attempts = attemptsByStageKey.get(stage.getStageKey()); + if (attempts != null) { + rs.setAttempts(attempts.stream().map(mapper::toAttemptRS).collect(Collectors.toList())); + } + stageRS.add(rs); + } + var users = users(List.of(iteration)); + var refs = rerunSources(List.of(iteration)); + return mapper.toIterationDetailRS(iteration, effectiveAttributes(iteration, stages), stageRS, + lookup(users, iteration.getCreatedBy()), lookup(refs, iteration.getRerunOfIterationId())); + } + + /** + * The attributes the iteration was reported with plus those summed up from its stages. + */ + List effectiveAttributes(PipelineIteration iteration, List stages) { + var own = iteration.getAttributes() == null ? List.of() : iteration.getAttributes().getItems(); + var reserved = own.stream().map(PipelineAttribute::getKey).collect(Collectors.toSet()); + var rolled = PipelineAttributeSupport.rollup(stages, reserved); + if (own.isEmpty() && rolled.isEmpty()) { + return null; + } + var all = new ArrayList<>(own); + all.addAll(rolled); + return all; + } + + private static V lookup(Map map, Long key) { + return key == null ? null : map.get(key); + } + + private Map users(Collection iterations) { + var ids = iterations.stream().map(PipelineIteration::getCreatedBy).filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (ids.isEmpty()) { + return Map.of(); + } + return mapper.toUserMap(userRepository.findAllById(ids)); + } + + private Map rerunSources(Collection iterations) { + var ids = iterations.stream().map(PipelineIteration::getRerunOfIterationId).filter(Objects::nonNull) + .collect(Collectors.toCollection(HashSet::new)); + if (ids.isEmpty()) { + return Map.of(); + } + var refs = new HashMap(); + iterationRepository.findAllById(ids).forEach(source -> refs.put(source.getId(), + PipelineIterationRefRS.builder().id(source.getId()).iterationNumber(source.getIterationNumber()).build())); + return refs; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/configs/DataSourceConfig.java b/src/main/java/com/epam/reportportal/base/core/configs/DataSourceConfig.java index 5af40dc63d..dc17967128 100644 --- a/src/main/java/com/epam/reportportal/base/core/configs/DataSourceConfig.java +++ b/src/main/java/com/epam/reportportal/base/core/configs/DataSourceConfig.java @@ -21,6 +21,7 @@ import io.zonky.test.db.postgres.embedded.EmbeddedPostgres; import java.io.File; import java.io.IOException; +import java.util.UUID; import javax.sql.DataSource; import org.postgresql.ds.PGSimpleDataSource; import org.slf4j.Logger; @@ -56,9 +57,16 @@ public DataSource dataSource() { public DataSource testDataSource(@Value("${embedded.datasource.dir}") String dataDir, @Value("${embedded.datasource.clean}") Boolean clean, @Value("${embedded.datasource.port}") Integer port) throws IOException { + // Every distinct Spring test context configuration (e.g. a test class that adds its own + // @MockBean on top of BaseMvcTest's set) gets its own ApplicationContext and therefore its + // own instance of this bean. Spring's test context cache keeps multiple such contexts alive + // at once, so a shared, fixed data directory here would let one context's initdb collide + // with another still-running context's live Postgres process on the same files. A unique + // subdirectory per bean instance keeps concurrently-cached contexts isolated from each other. + final File instanceDataDir = new File(dataDir, UUID.randomUUID().toString()); final EmbeddedPostgres.Builder builder = EmbeddedPostgres.builder() .setPort(port) - .setDataDirectory(new File(dataDir)) + .setDataDirectory(instanceDataDir) .setCleanDataDirectory(clean); DataSource dataSource = builder.start().getPostgresDatabase(); log.info("Database started on port: {}", ((PGSimpleDataSource) dataSource).getPortNumbers()); diff --git a/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseController.java b/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseController.java index 69495b027b..6b97d34878 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseController.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseController.java @@ -4,6 +4,7 @@ import static com.epam.reportportal.base.auth.permissions.Permissions.ALLOWED_TO_VIEW_PROJECT; import com.epam.reportportal.base.core.tms.dto.DeleteTagsRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.dto.TmsTestFolderRS; @@ -211,6 +212,36 @@ public TmsTestCaseRS patchTestCase(@PathVariable("projectKey") String projectKey ); } + /** + * Records AI quality scores and/or generation cost for a test case's current default + * version. Dedicated endpoint for the AI agent, separate from create/patch — submitting + * this is what flips {@code origin} to {@code AI} and defaults {@code status} to + * {@code DRAFT} the first time it happens. + * + * @param projectKey The key of the project to which the test case belongs. + * @param testCaseId The ID of the test case to record scores/generation for. + * @param inputDto A request payload ({@link TmsTestCaseGenerationRQ}) with quality scores + * and/or generation metadata. + * @return A data transfer object ({@link TmsTestCaseRS}) with the updated test case. + */ + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @PostMapping("/{testCaseId}/generation") + @Operation( + summary = "Record AI quality scores and generation metadata for a test case", + description = "Records AI quality scores and/or generation cost for a test case's " + + "current default version, separate from editing its content." + ) + @ApiResponse(responseCode = "200", description = "Generation data recorded successfully") + public TmsTestCaseRS applyGeneration(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") final long testCaseId, + @RequestBody @Valid final TmsTestCaseGenerationRQ inputDto, + @AuthenticationPrincipal ReportPortalUser user) { + var projectId = projectExtractor + .extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)) + .getProjectId(); + return tmsTestCaseService.applyGeneration(projectId, testCaseId, inputDto); + } + /** * Deletes a specific test case by its ID within a project. * diff --git a/src/main/java/com/epam/reportportal/base/core/tms/controller/TmsQualityStandardController.java b/src/main/java/com/epam/reportportal/base/core/tms/controller/TmsQualityStandardController.java new file mode 100644 index 0000000000..2a80dd501f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/controller/TmsQualityStandardController.java @@ -0,0 +1,93 @@ +package com.epam.reportportal.base.core.tms.controller; + +import static com.epam.reportportal.base.auth.permissions.Permissions.ALLOWED_TO_EDIT_PROJECT; +import static com.epam.reportportal.base.auth.permissions.Permissions.ALLOWED_TO_VIEW_PROJECT; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRS; +import com.epam.reportportal.base.core.tms.service.TmsQualityStandardService; +import com.epam.reportportal.base.infrastructure.persistence.commons.EntityUtils; +import com.epam.reportportal.base.infrastructure.persistence.commons.ReportPortalUser; +import com.epam.reportportal.base.util.ProjectExtractor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Per-project AI test-case quality rubric (one per project, MVP). See B.1-B.4 + * of the reviewed API contract. + */ +@RestController +@RequestMapping("/v1/project/{projectKey}/tms/quality-standard") +@Tag(name = "TMS Quality Standard", description = "AI test-case grading rubric") +@RequiredArgsConstructor +public class TmsQualityStandardController { + + private final TmsQualityStandardService tmsQualityStandardService; + private final ProjectExtractor projectExtractor; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @Operation(summary = "Get the project's quality standard", + description = "Timestamps are epoch milliseconds. 404 when the project has no standard.") + public TmsQualityStandardRS getStandard( + @PathVariable String projectKey, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return tmsQualityStandardService.getStandard(projectId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Create the project's quality standard") + public TmsQualityStandardRS createStandard( + @PathVariable String projectKey, + @Valid @RequestBody TmsQualityStandardRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return tmsQualityStandardService.createStandard(projectId, rq); + } + + @PutMapping + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Update the project's quality standard") + public TmsQualityStandardRS updateStandard( + @PathVariable String projectKey, + @Valid @RequestBody TmsQualityStandardRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return tmsQualityStandardService.updateStandard(projectId, rq); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @Operation(summary = "Remove the project's quality standard", + description = "Rejected with 400 when test cases already have scores against its criteria.") + public void deleteStandard( + @PathVariable String projectKey, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + tmsQualityStandardService.deleteStandard(projectId); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRQ.java new file mode 100644 index 0000000000..29b0d2f32d --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRQ.java @@ -0,0 +1,40 @@ +package com.epam.reportportal.base.core.tms.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsQualityStandardCriterionRQ { + + /** + * Id of an existing criterion to update in place. {@code null} creates a new + * criterion; an existing criterion whose id is absent from the request is removed. + */ + private Long id; + + @NotBlank + private String name; + + @Schema(description = "Optional stable machine-readable key (lower snake case, unique within the standard); graders may report scores by it instead of the numeric id") + @Pattern(regexp = "^[a-z][a-z0-9_]{0,63}$", message = "must be lower snake case, up to 64 characters") + private String key; + + @NotNull + @Min(1) + @Max(100) + private Integer maxPoints; + + @NotNull + private Integer sequence; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRS.java new file mode 100644 index 0000000000..a2973dc80e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRS.java @@ -0,0 +1,24 @@ +package com.epam.reportportal.base.core.tms.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsQualityStandardCriterionRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + @Schema(description = "Stable machine-readable key; null when not assigned") + private String key; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer maxPoints; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Integer sequence; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRQ.java new file mode 100644 index 0000000000..77fb802c88 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRQ.java @@ -0,0 +1,28 @@ +package com.epam.reportportal.base.core.tms.dto; + +import com.epam.reportportal.base.core.tms.validation.ValidQualityStandardRQ; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ValidQualityStandardRQ +public class TmsQualityStandardRQ { + + @NotBlank + private String name; + + private String description; + + @NotEmpty + @Valid + private List criteria; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRS.java new file mode 100644 index 0000000000..798e55672b --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRS.java @@ -0,0 +1,26 @@ +package com.epam.reportportal.base.core.tms.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsQualityStandardRS { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String name; + private String description; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List criteria; + @Schema(description = "Creation time, epoch milliseconds", example = "1767225600000") + private Long createdAt; + @Schema(description = "Last update time, epoch milliseconds", example = "1767225600000") + private Long updatedAt; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationMetadataRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationMetadataRQ.java new file mode 100644 index 0000000000..f99465d7ca --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationMetadataRQ.java @@ -0,0 +1,19 @@ +package com.epam.reportportal.base.core.tms.dto; + +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseGenerationMetadataRQ { + private Integer tokensIn; + private Integer tokensOut; + private String model; + private String skill; + private BigDecimal costUsd; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationRQ.java new file mode 100644 index 0000000000..ba9eab02cb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationRQ.java @@ -0,0 +1,28 @@ +package com.epam.reportportal.base.core.tms.dto; + +import jakarta.validation.Valid; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Payload for {@code POST .../tms/test-case/{testCaseId}/generation} — the dedicated + * endpoint an AI agent uses to report quality scores and generation cost for the test + * case's current default version, separate from editing its content + * ({@link TmsTestCaseRQ}). Submitting this is what flips {@code origin} to {@code AI} + * and applies the {@code status} ratchet — see {@code PIPELINE_AGENT_INTEGRATION_GUIDE.md}. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseGenerationRQ { + + @Valid + private List qualityScores; + + @Valid + private TmsTestCaseGenerationMetadataRQ generation; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseMetricsRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseMetricsRS.java new file mode 100644 index 0000000000..da690fa3ef --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseMetricsRS.java @@ -0,0 +1,56 @@ +package com.epam.reportportal.base.core.tms.dto; + +import java.math.BigDecimal; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Server-computed AI quality score (against the project's Quality Standard) and + * generation cost for a test case's current default version. {@code null} when + * the test case has no recorded scores/generation data for that version + * (typically a manual test case, or an AI one whose content changed since it + * was last scored). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseMetricsRS { + + private Integer overallScore; + + private Long evaluatedAt; + + /** + * {@code true} when the version's manual scenario was modified after + * {@code evaluatedAt} — the recorded score no longer reflects the current + * content. + */ + private boolean obsolete; + + private List criteria; + + private Integer tokensIn; + + private Integer tokensOut; + + private String model; + + private String skill; + + private BigDecimal costUsd; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CriterionScore { + private Long criterionId; + private String name; + private Integer maxPoints; + private Integer score; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseOrigin.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseOrigin.java new file mode 100644 index 0000000000..0382606d81 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseOrigin.java @@ -0,0 +1,9 @@ +package com.epam.reportportal.base.core.tms.dto; + +/** + * Who created a TMS test case. + */ +public enum TmsTestCaseOrigin { + MANUAL, + AI +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseQualityScoreRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseQualityScoreRQ.java new file mode 100644 index 0000000000..260c11551b --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseQualityScoreRQ.java @@ -0,0 +1,26 @@ +package com.epam.reportportal.base.core.tms.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseQualityScoreRQ { + + @Schema(description = "Id of the criterion; exactly one of criterionId and criterionKey is required") + private Long criterionId; + + @Schema(description = "Stable key of the criterion; exactly one of criterionId and criterionKey is required") + private String criterionKey; + + @NotNull + @Min(0) + private Integer score; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRQ.java index 880be84d0a..d8394d3431 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRQ.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRQ.java @@ -30,4 +30,12 @@ public class TmsTestCaseRQ { @Valid private TmsManualScenarioRQ manualScenario; + + /** + * On create: defaults to {@code READY} for manual test cases; defaults to + * {@code DRAFT} when the AI agent reports this as an AI-authored case via + * {@code POST .../generation} (see {@link TmsTestCaseGenerationRQ}), unless explicitly + * set here. Honored on patch: changes status only when present. + */ + private TmsTestCaseStatus status; } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRS.java index d903d4c50c..595f9c2f77 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRS.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseRS.java @@ -37,4 +37,10 @@ public class TmsTestCaseRS { private TmsManualScenarioRS manualScenario; private Set attributes; + + private TmsTestCaseOrigin origin; + + private TmsTestCaseStatus status; + + private TmsTestCaseMetricsRS metrics; } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseStatus.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseStatus.java new file mode 100644 index 0000000000..4c64066c9e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseStatus.java @@ -0,0 +1,9 @@ +package com.epam.reportportal.base.core.tms.dto; + +/** + * Draft/Ready lifecycle of a TMS test case. + */ +public enum TmsTestCaseStatus { + DRAFT, + READY +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationError.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationError.java index df5bc42205..eaaea12236 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationError.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationError.java @@ -1,5 +1,6 @@ package com.epam.reportportal.base.core.tms.dto.batch; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -10,4 +11,10 @@ public class BatchTestCaseOperationError { private Long testCaseId; private String errorMessage; + @JsonInclude(JsonInclude.Include.NON_NULL) + private BatchTestCaseOperationErrorReason reason; + + public BatchTestCaseOperationError(Long testCaseId, String errorMessage) { + this(testCaseId, errorMessage, null); + } } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationErrorReason.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationErrorReason.java new file mode 100644 index 0000000000..e7fcae6753 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationErrorReason.java @@ -0,0 +1,17 @@ +package com.epam.reportportal.base.core.tms.dto.batch; + +/** + * Stable machine-readable reason for a per-test-case failure in a batch operation; clients should + * branch on this rather than on the human-readable {@code errorMessage}. Absent for failures that + * have no dedicated code. + */ +public enum BatchTestCaseOperationErrorReason { + /** The test case does not exist in the project. */ + NOT_FOUND, + /** The test case id appears more than once in the request. */ + DUPLICATE, + /** The test case is already in the target (launch or test plan). */ + ALREADY_EXISTS, + /** The test case is not Ready, so it cannot be added to a launch or test plan. */ + NOT_READY +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/event/TmsTestCasesLifecycleChangedEvent.java b/src/main/java/com/epam/reportportal/base/core/tms/event/TmsTestCasesLifecycleChangedEvent.java new file mode 100644 index 0000000000..fa28a58ed6 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/event/TmsTestCasesLifecycleChangedEvent.java @@ -0,0 +1,16 @@ +package com.epam.reportportal.base.core.tms.event; + +import java.util.Collection; +import java.util.Set; + +/** + * Published when the lifecycle status of test cases may have changed or the cases are about to be + * deleted. For deletions it has to be published before the cases are removed, while whatever links + * them to other entities (e.g. pipeline stages) still exists. + */ +public record TmsTestCasesLifecycleChangedEvent(Set testCaseIds) { + + public TmsTestCasesLifecycleChangedEvent(Collection testCaseIds) { + this(Set.copyOf(testCaseIds)); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsQualityStandardMapper.java b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsQualityStandardMapper.java new file mode 100644 index 0000000000..bace9d0fc8 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsQualityStandardMapper.java @@ -0,0 +1,70 @@ +package com.epam.reportportal.base.core.tms.mapper; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRS; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRS; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandard; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandardCriterion; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +@Component +public class TmsQualityStandardMapper { + + public TmsQualityStandardRS toRS(TmsQualityStandard standard) { + return TmsQualityStandardRS.builder() + .id(standard.getId()) + .name(standard.getName()) + .description(standard.getDescription()) + .criteria(standard.getCriteria() == null ? List.of() : standard.getCriteria().stream() + .map(this::toCriterionRS) + .collect(Collectors.toList())) + .createdAt(standard.getCreatedAt() == null ? null : standard.getCreatedAt().toEpochMilli()) + .updatedAt(standard.getUpdatedAt() == null ? null : standard.getUpdatedAt().toEpochMilli()) + .build(); + } + + private TmsQualityStandardCriterionRS toCriterionRS(TmsQualityStandardCriterion criterion) { + return TmsQualityStandardCriterionRS.builder() + .id(criterion.getId()) + .key(criterion.getKey()) + .name(criterion.getName()) + .maxPoints(criterion.getMaxPoints()) + .sequence(criterion.getSequence()) + .build(); + } + + /** + * Builds the criteria list for a (re)saved standard, upserting by {@code id}: + * an existing criterion whose id is present in {@code criteriaRQ} is mutated in + * place (so Hibernate updates it instead of deleting+recreating it under + * {@code orphanRemoval}); a criterion with no id, or whose id isn't found, is + * created new. A criterion whose id is absent from {@code criteriaRQ} is left + * out of the result, which is what removes it via {@code orphanRemoval}. + */ + public List mergeCriteria(TmsQualityStandard standard, + List criteriaRQ) { + var existingById = (standard.getCriteria() == null ? List.of() : standard.getCriteria()) + .stream() + .filter(criterion -> criterion.getId() != null) + .collect(Collectors.toMap(TmsQualityStandardCriterion::getId, Function.identity())); + + return criteriaRQ.stream() + .map(rq -> { + var criterion = rq.getId() == null ? null : existingById.get(rq.getId()); + if (criterion == null) { + criterion = new TmsQualityStandardCriterion(); + criterion.setStandard(standard); + } + criterion.setKey(rq.getKey()); + criterion.setName(rq.getName()); + criterion.setMaxPoints(rq.getMaxPoints()); + criterion.setSequence(rq.getSequence()); + return criterion; + }) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseMapper.java b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseMapper.java index 3ae6375164..00cfb83817 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseMapper.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseMapper.java @@ -5,6 +5,7 @@ import com.epam.reportportal.base.core.tms.dto.TmsTestCaseExecutionInTestPlanRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseExecutionLaunchRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseInTestPlanRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseMetricsRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.dto.batch.BatchPatchTestCasesRQ; @@ -55,9 +56,14 @@ public abstract class TmsTestCaseMapper implements DtoMapper convert( Collection testCases, Map testCaseDefaultVersions, Map testCaseExecutions, + Map metricsByVersionId, Pageable pageable, long totalCount) { var tmsTestCaseRSList = testCases .stream() - .map(testCase -> convert( - testCase, - testCaseDefaultVersions.get(testCase.getId()), - testCaseExecutions.get(testCase.getId()))) + .map(testCase -> { + var defaultVersion = testCaseDefaultVersions.get(testCase.getId()); + var metrics = defaultVersion == null ? null : metricsByVersionId.get(defaultVersion.getId()); + return convert( + testCase, + defaultVersion, + testCaseExecutions.get(testCase.getId()), + metrics); + }) .toList(); return new PageImpl<>( tmsTestCaseRSList, pageable, totalCount @@ -99,6 +116,7 @@ public Page convert( @Mapping(target = "versions", ignore = true) @Mapping(target = "priority", source = "tmsTestCaseRQ.priority", qualifiedByName = "normalizePriority") + @Mapping(target = "status", ignore = true) //defaults to READY (entity default); service overrides for AI-signalled creates public abstract TmsTestCase convertFromRQ(Long projectId, TmsTestCaseRQ tmsTestCaseRQ, Long testFolderId); @@ -124,6 +142,8 @@ public abstract TmsTestCase convertFromPatchRQ(Long projectId, TmsTestCaseRQ tms @Mapping(target = "versions", ignore = true) @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "origin", ignore = true) //origin/status evolve only through dedicated flows, not blanket upsert + @Mapping(target = "status", ignore = true) public abstract void update(@MappingTarget TmsTestCase targetTestCase, TmsTestCase tmsTestCase); @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE, @@ -136,6 +156,7 @@ public abstract TmsTestCase convertFromPatchRQ(Long projectId, TmsTestCaseRQ tms @Mapping(target = "versions", ignore = true) @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "origin", ignore = true) //never a request field; service ratchets MANUAL->AI on AI signal public abstract void patch(@MappingTarget TmsTestCase existingTestCase, TmsTestCase tmsTestCase); @@ -164,6 +185,10 @@ protected TmsTestFolder convertToTmsTestFolder(Long tmsTestFolderId, @Mapping(target = "displayId", expression = "java(tmsDisplayIdService.generateTestCaseDisplayId(originalTestCase.getProject().getId()))") @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) + //a duplicate is a fresh, human-initiated copy: starts MANUAL/READY (entity defaults) rather + //than inheriting the original's AI marker/lifecycle status, which was earned by that content + @Mapping(target = "origin", ignore = true) + @Mapping(target = "status", ignore = true) @Mapping(target = "name", source = "originalTestCase.name") @Mapping(target = "priority", source = "originalTestCase.priority") @Mapping(target = "description", source = "originalTestCase.description") diff --git a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseVersionMapper.java b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseVersionMapper.java index 0e19dc34a8..2b230ba023 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseVersionMapper.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsTestCaseVersionMapper.java @@ -3,6 +3,7 @@ import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; import com.epam.reportportal.base.core.tms.mapper.config.CommonMapperConfig; +import java.time.Instant; import org.mapstruct.BeanMapping; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -16,6 +17,7 @@ public interface TmsTestCaseVersionMapper { default TmsTestCaseVersion createDefaultTestCaseVersion() { var testCaseVersion = new TmsTestCaseVersion(); testCaseVersion.setDefault(true); + testCaseVersion.setUpdatedAt(Instant.now()); return testCaseVersion; } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassed.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassed.java new file mode 100644 index 0000000000..d7c539690d --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassed.java @@ -0,0 +1,52 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeRollup; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.service.PipelineStageReportedHandler; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Marks the test cases of a stage as Ready on their own when the pipeline has Auto-Ready switched on and the + * stage has just passed. + *

+ * Example: the pipeline has {@code {"autoReady": {"enabled": true, "threshold": 90}}}. Its Upload stage + * passes with test cases TC-1 (score 96), TC-2 (score 85) and TC-3 (score 88). TC-1 is promoted to Ready, and + * the handler adds {@code autoReadyPromoted = 1} to the stage. The iteration sums that attribute over its + * stages, so its totals can show "1 of 3 Auto-Ready promoted". + */ +@Component +@RequiredArgsConstructor +public class AutoReadyOnStagePassed implements PipelineStageReportedHandler { + + static final String PROMOTED_ATTRIBUTE_KEY = "autoReadyPromoted"; + static final String THRESHOLD_ATTRIBUTE_KEY = "autoReadyThreshold"; + + private final PipelineStageTestCaseRepository stageTestCaseRepository; + private final TmsTestCaseService tmsTestCaseService; + + @Override + public List onStageReported(StageReport report) { + if (!report.stagePassed()) { + return List.of(); + } + var autoReady = AutoReadySettings.of(report.pipeline()).filter(AutoReadySettings::active).orElse(null); + if (autoReady == null) { + return List.of(); + } + var testCases = stageTestCaseRepository.findTestCasesByStageId(report.stage().getId()); + var promoted = tmsTestCaseService.applyAutoReadyBatch(report.projectId(), testCases, autoReady.threshold()); + return List.of( + PipelineAttribute.builder().key(PROMOTED_ATTRIBUTE_KEY).label("Auto-Ready promoted") + .value(String.valueOf(promoted)).type(AttributeType.META).valueType(AttributeValueType.NUMBER) + .rollup(AttributeRollup.SUM).build(), + PipelineAttribute.builder().key(THRESHOLD_ATTRIBUTE_KEY).label("Auto-Ready threshold") + .value(String.valueOf(autoReady.threshold())).type(AttributeType.NONE) + .valueType(AttributeValueType.NUMBER).build()); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettings.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettings.java new file mode 100644 index 0000000000..c971e6baca --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettings.java @@ -0,0 +1,44 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import java.util.Map; +import java.util.Optional; + +/** + * The test case Auto-Ready setting of a pipeline: whether test cases an AI pipeline produced are marked Ready + * on their own when their quality score reaches a threshold. + *

+ * Stored in the pipeline's settings under the name {@value #SETTING_NAME}. Example: + * {@code {"autoReady": {"enabled": true, "threshold": 90}}} promotes every produced test case that scored 90 + * or more; with {@code "enabled": false}, or without the setting, nothing is promoted. + */ +record AutoReadySettings(boolean enabled, Integer threshold) { + + static final String SETTING_NAME = "autoReady"; + + /** + * The Auto-Ready setting of the pipeline if it has one; a value of an unexpected shape reads as "not set". + */ + static Optional of(Pipeline pipeline) { + if (pipeline.getSettings() == null) { + return Optional.empty(); + } + return of(pipeline.getSettings().getValues().get(SETTING_NAME)); + } + + static Optional of(Object rawSetting) { + if (!(rawSetting instanceof Map values)) { + return Optional.empty(); + } + var enabled = Boolean.TRUE.equals(values.get("enabled")); + var threshold = values.get("threshold") instanceof Number number ? number.intValue() : null; + return Optional.of(new AutoReadySettings(enabled, threshold)); + } + + /** + * Whether test cases should be promoted: it is switched on and has a threshold. + */ + boolean active() { + return enabled && threshold != null; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidator.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidator.java new file mode 100644 index 0000000000..e40a1f8927 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidator.java @@ -0,0 +1,34 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.service.PipelineSettingsValidator; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.Map; +import org.springframework.stereotype.Component; + +/** + * Checks the {@code autoReady} setting of a pipeline and ignores every other setting. + *

+ * Example: {@code {"autoReady": {"enabled": true, "threshold": 90}}} is accepted; + * {@code {"autoReady": {"enabled": true}}} and {@code {"autoReady": {"enabled": true, "threshold": 120}}} are + * rejected, because switching Auto-Ready on needs a threshold between 0 and 100. + */ +@Component +public class AutoReadySettingsValidator implements PipelineSettingsValidator { + + @Override + public void validate(Pipeline pipeline, Map settings) { + var rawSetting = settings.get(AutoReadySettings.SETTING_NAME); + if (rawSetting == null) { + return; + } + var autoReady = AutoReadySettings.of(rawSetting).orElseThrow(() -> new ReportPortalException( + ErrorType.BAD_REQUEST_ERROR, "'autoReady' must be an object like {\"enabled\": true, \"threshold\": 90}")); + if (autoReady.enabled() && (autoReady.threshold() == null || autoReady.threshold() < 0 + || autoReady.threshold() > 100)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "'autoReady.threshold' (0-100) is required when 'autoReady.enabled' is true"); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChange.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChange.java new file mode 100644 index 0000000000..4e332e80b1 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChange.java @@ -0,0 +1,50 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationRefresher; +import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * Recounts the review progress of a pipeline iteration when one of its test cases changes. + *

+ * Example: iteration #4 produced test cases TC-1..TC-3 and its Review stage shows "1 / 3 Ready". A reviewer + * marks TC-2 as Ready in the Library; this listener finds iteration #4 through the link between its stage + * and TC-2 and refreshes it, so the Review stage shows "2 / 3 Ready" without any new report from the + * pipeline. + *

+ * The iterations are looked up when the event arrives (a deleted case no longer has its link afterwards) + * and refreshed once the change has been committed. + */ +@Component +@RequiredArgsConstructor +public class RefreshPipelineIterationsOnTestCaseChange { + + private final PipelineStageTestCaseRepository stageTestCaseRepository; + private final PipelineIterationRefresher refresher; + + @EventListener + public void onLifecycleChanged(TmsTestCasesLifecycleChangedEvent event) { + if (event.testCaseIds().isEmpty()) { + return; + } + var iterationIds = stageTestCaseRepository.findIterationIdsByTestCaseIdIn(event.testCaseIds()); + if (iterationIds.isEmpty()) { + return; + } + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + refresher.refreshIterations(iterationIds); + } + }); + } else { + refresher.refreshIterations(iterationIds); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolver.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolver.java new file mode 100644 index 0000000000..a18885cd5e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolver.java @@ -0,0 +1,68 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.service.PipelineRelatedEntityResolver; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.PipelineStageTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Links a stage to the test cases it reports as related entities of type {@code TEST_CASE}, by display id. + *

+ * Example: the Upload stage reports {@code [{entityType: "TEST_CASE", entityId: "TC-12"}]}; this resolver + * finds TC-12 in the project's Test Case Library and links it to the stage, so the Review stage can later + * count how many of the iteration's test cases are Ready. An id that does not exist (e.g. "TC-404") is + * returned as not found. + */ +@Component +@RequiredArgsConstructor +public class TestCaseRelatedEntityResolver implements PipelineRelatedEntityResolver { + + public static final String TYPE = "TEST_CASE"; + + private final TmsTestCaseService tmsTestCaseService; + private final PipelineStageTestCaseRepository stageTestCaseRepository; + + @Override + public String entityType() { + return TYPE; + } + + @Override + public List resolve(Long projectId, Pipeline pipeline, PipelineStage stage, List displayIds) { + stageTestCaseRepository.deleteByStage_Id(stage.getId()); + if (displayIds.isEmpty()) { + return List.of(); + } + + var testCasesByDisplayId = tmsTestCaseService.getEntitiesByDisplayIds(projectId, displayIds).stream() + .collect(Collectors.toMap(TmsTestCase::getDisplayId, Function.identity())); + + var links = new ArrayList(); + var notFound = new ArrayList(); + for (var displayId : displayIds) { + var testCase = testCasesByDisplayId.get(displayId); + if (testCase == null) { + notFound.add(displayId); + continue; + } + var link = new PipelineStageTestCase(); + link.setStage(stage); + link.setTestCase(testCase); + links.add(link); + } + if (!links.isEmpty()) { + stageTestCaseRepository.saveAll(links); + } + + return notFound; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributor.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributor.java new file mode 100644 index 0000000000..fd2af35666 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributor.java @@ -0,0 +1,126 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.service.PipelineStageContributor; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Owns the human review stage of a test case generation pipeline: it exists from the start but only + * counts once the upload stage has passed, then shows how many of the iteration's test cases are Ready + * and stays in progress until all of them are. + */ +@Component +@RequiredArgsConstructor +public class TestCaseReviewContributor implements PipelineStageContributor { + + public static final String PIPELINE_TYPE = "TEST_CASE_GENERATION"; + static final String REVIEW_KEY = "review"; + static final String UPLOAD_KEY = "upload"; + + private final PipelineStageTestCaseRepository stageTestCaseRepository; + + @Override + public boolean supports(Pipeline pipeline) { + return PIPELINE_TYPE.equals(pipeline.getType()); + } + + @Override + public List contribute(PipelineIteration iteration, List stages) { + var review = stages.stream().filter(stage -> REVIEW_KEY.equals(stage.getStageKey())).findFirst() + .orElseGet(() -> newReviewStage(iteration, stages)); + if (review.getId() != null && review.getOwnedBy() != StageOwner.REPORTPORTAL) { + // the pipeline reports a stage of its own under this key; leave it alone + return List.of(); + } + var uploadPassed = stages.stream() + .anyMatch(stage -> UPLOAD_KEY.equals(stage.getStageKey()) + && stage.getStatusCategory() == StatusCategory.PASSED); + + if (!uploadPassed) { + review.setStatus("Pending"); + review.setStatusCategory(StatusCategory.PENDING); + review.setAffectsIterationStatus(false); + review.setMessage("Waiting for Upload"); + return List.of(review); + } + + var counts = iteration.getId() == null ? List.of() + : stageTestCaseRepository.countTestCasesByIterationIdIn(List.of(iteration.getId()), TmsTestCaseStatus.READY); + long total = counts.isEmpty() ? 0 : counts.get(0).getTestCasesCount(); + long ready = counts.isEmpty() ? 0 : counts.get(0).getReadyCount(); + boolean done = ready == total; + + review.setAffectsIterationStatus(true); + review.setStatus(done ? "Done" : "In progress"); + review.setStatusCategory(done ? StatusCategory.PASSED : StatusCategory.WAITING); + review.setSummary(ready + "/" + total + " Ready"); + review.setMessage(done ? "All Test Cases of this iteration are Ready" + : (total - ready) + " Draft Test Case" + (total - ready == 1 ? " waits" : "s wait") + + " for review in the Test Case Library"); + if (review.getStartedAt() == null) { + review.setStartedAt(Instant.now()); + } + review.setFinishedAt(done ? Objects.requireNonNullElseGet(review.getFinishedAt(), Instant::now) : null); + review.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("review").label("Ready").value(ready + "/" + total) + .type(AttributeType.META).valueType(AttributeValueType.TEXT).build(), + PipelineAttribute.builder().key("ready").label("Ready").value(String.valueOf(ready)) + .type(AttributeType.NONE).valueType(AttributeValueType.NUMBER).build(), + PipelineAttribute.builder().key("total").label("Test Cases").value(String.valueOf(total)) + .type(AttributeType.NONE).valueType(AttributeValueType.NUMBER).build()))); + return List.of(review); + } + + @Override + public Optional iterationStatusLabel(StatusCategory category) { + return switch (category) { + case WAITING -> Optional.of("In review"); + case PASSED -> Optional.of("Completed"); + default -> Optional.empty(); + }; + } + + @Override + public List iterationAttributes(PipelineIteration iteration, List stages) { + return stages.stream() + .filter(stage -> REVIEW_KEY.equals(stage.getStageKey()) && stage.getOwnedBy() == StageOwner.REPORTPORTAL) + .findFirst() + .map(PipelineStage::getAttributes) + .flatMap(attributes -> attributes.getItems().stream() + .filter(attribute -> "ready".equals(attribute.getKey())) + .findFirst()) + .map(ready -> List.of(PipelineAttribute.builder().key("readyNow").label("Ready now") + .value(ready.getValue()).type(AttributeType.META).valueType(AttributeValueType.NUMBER).build())) + .orElse(List.of()); + } + + private PipelineStage newReviewStage(PipelineIteration iteration, List stages) { + var review = new PipelineStage(); + review.setIteration(iteration); + review.setStageKey(REVIEW_KEY); + review.setName("Review"); + review.setSequence(stages.stream().map(PipelineStage::getSequence).filter(Objects::nonNull) + .max(Integer::compare).orElse(0) + 1); + review.setOwnedBy(StageOwner.REPORTPORTAL); + review.setAttempt(1); + review.setStatus("Pending"); + review.setStatusCategory(StatusCategory.PENDING); + review.setAffectsIterationStatus(false); + return review; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardService.java new file mode 100644 index 0000000000..dd8f7fd4d8 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardService.java @@ -0,0 +1,15 @@ +package com.epam.reportportal.base.core.tms.service; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRS; + +public interface TmsQualityStandardService { + + TmsQualityStandardRS getStandard(Long projectId); + + TmsQualityStandardRS createStandard(Long projectId, TmsQualityStandardRQ rq); + + TmsQualityStandardRS updateStandard(Long projectId, TmsQualityStandardRQ rq); + + void deleteStandard(Long projectId); +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImpl.java new file mode 100644 index 0000000000..41ac8ca82e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImpl.java @@ -0,0 +1,128 @@ +package com.epam.reportportal.base.core.tms.service; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRS; +import com.epam.reportportal.base.core.tms.mapper.TmsQualityStandardMapper; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsQualityStandardRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseQualityScoreRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandard; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandardCriterion; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.Objects; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TmsQualityStandardServiceImpl implements TmsQualityStandardService { + + private final TmsQualityStandardRepository tmsQualityStandardRepository; + private final TmsTestCaseQualityScoreRepository tmsTestCaseQualityScoreRepository; + private final TmsQualityStandardMapper tmsQualityStandardMapper; + + @Override + @Transactional(readOnly = true) + public TmsQualityStandardRS getStandard(Long projectId) { + return tmsQualityStandardMapper.toRS(getOrThrow(projectId)); + } + + @Override + @Transactional + public TmsQualityStandardRS createStandard(Long projectId, TmsQualityStandardRQ rq) { + if (tmsQualityStandardRepository.existsByProjectId(projectId)) { + throw new ReportPortalException(ErrorType.RESOURCE_ALREADY_EXISTS, + "Quality standard for project '" + projectId + "'"); + } + var standard = new TmsQualityStandard(); + standard.setProjectId(projectId); + applyRQ(standard, rq); + return tmsQualityStandardMapper.toRS(tmsQualityStandardRepository.save(standard)); + } + + @Override + @Transactional + public TmsQualityStandardRS updateStandard(Long projectId, TmsQualityStandardRQ rq) { + var standard = getOrThrow(projectId); + applyRQ(standard, rq); + return tmsQualityStandardMapper.toRS(tmsQualityStandardRepository.save(standard)); + } + + @Override + @Transactional + public void deleteStandard(Long projectId) { + // criterion -> score is ON DELETE CASCADE, so deleting a standard that has scored test cases + // would silently wipe their scoring history; same rule as removing a scored criterion on PUT. + if (tmsTestCaseQualityScoreRepository.existsByCriterionStandardProjectId(projectId)) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Cannot delete the quality standard: test cases already have scores against its criteria"); + } + tmsQualityStandardRepository.deleteByProjectId(projectId); + } + + private void applyRQ(TmsQualityStandard standard, TmsQualityStandardRQ rq) { + standard.setName(rq.getName()); + standard.setDescription(rq.getDescription()); + guardAgainstDuplicateKeys(rq); + guardAgainstRemovingScoredCriteria(standard, rq); + var criteria = tmsQualityStandardMapper.mergeCriteria(standard, rq.getCriteria()); + if (standard.getCriteria() == null) { + standard.setCriteria(criteria); + } else { + standard.getCriteria().clear(); + standard.getCriteria().addAll(criteria); + } + } + + private void guardAgainstDuplicateKeys(TmsQualityStandardRQ rq) { + var duplicateKeys = rq.getCriteria().stream() + .map(TmsQualityStandardCriterionRQ::getKey) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy(key -> key, Collectors.counting())) + .entrySet().stream() + .filter(entry -> entry.getValue() > 1) + .map(java.util.Map.Entry::getKey) + .toList(); + if (!duplicateKeys.isEmpty()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Duplicate quality standard criterion keys: " + duplicateKeys); + } + } + + /** + * Criteria dropped from the request are deleted via {@code orphanRemoval} once merged - refuse + * that here for any criterion historical {@link com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseQualityScore} + * rows still reference, since {@code tms_test_case_quality_score.criterion_id} cascades on + * delete and would silently destroy that scoring history. + */ + private void guardAgainstRemovingScoredCriteria(TmsQualityStandard standard, TmsQualityStandardRQ rq) { + if (standard.getCriteria() == null || standard.getCriteria().isEmpty()) { + return; + } + var keptIds = rq.getCriteria().stream() + .map(TmsQualityStandardCriterionRQ::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + var removedIds = standard.getCriteria().stream() + .map(TmsQualityStandardCriterion::getId) + .filter(id -> !keptIds.contains(id)) + .collect(Collectors.toSet()); + if (removedIds.isEmpty()) { + return; + } + var scoredIds = tmsTestCaseQualityScoreRepository.findDistinctCriterionIdsIn(removedIds); + if (!scoredIds.isEmpty()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Cannot remove quality standard criteria that already have test-case scores: " + scoredIds); + } + } + + private TmsQualityStandard getOrThrow(Long projectId) { + return tmsQualityStandardRepository.findByProjectId(projectId) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Quality standard for project '" + projectId + "'")); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImpl.java index 649fd2f6e6..aacc920d81 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImpl.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImpl.java @@ -13,7 +13,9 @@ import com.epam.reportportal.base.core.tms.dto.TmsTestCaseExecutionRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseExecutionRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseStatus; import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationError; +import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationErrorReason; import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationResultRS; import com.epam.reportportal.base.core.tms.mapper.NestedStepItemBuilder; import com.epam.reportportal.base.core.tms.mapper.TestCaseItemBuilder; @@ -361,19 +363,22 @@ public BatchTestCaseOperationResultRS addTestCasesToLaunch(long projectId, Launc if (!seenTestCaseIds.add(id)) { log.warn("Duplicate test case {} in request for launch: {}, skipping", id, launch.getId()); - errors.add(new BatchTestCaseOperationError(id, "Duplicate test case ID in request")); + errors.add(new BatchTestCaseOperationError(id, "Duplicate test case ID in request", + BatchTestCaseOperationErrorReason.DUPLICATE)); return false; } if (!existingTestCaseIds.contains(id)) { log.warn("Test case {} for project {} does not exist", id, projectId); - errors.add(new BatchTestCaseOperationError(id, "Test case does not exist in the project")); + errors.add(new BatchTestCaseOperationError(id, "Test case does not exist in the project", + BatchTestCaseOperationErrorReason.NOT_FOUND)); return false; } if (existingExecutionTestCaseIds.contains(id)) { log.warn("Execution for test case: {} already exists in launch: {}, skipping", id, launch.getId()); errors.add(new BatchTestCaseOperationError(id, - "Test case execution already exists in launch")); + "Test case execution already exists in launch", + BatchTestCaseOperationErrorReason.ALREADY_EXISTS)); return false; } return true; @@ -392,7 +397,17 @@ public BatchTestCaseOperationResultRS addTestCasesToLaunch(long projectId, Launc var testCase = testCasesMap.get(testCaseId); if (testCase == null) { log.warn("Test case {} for project {} not found in batch fetch", testCaseId, projectId); - errors.add(new BatchTestCaseOperationError(testCaseId, "Test case does not exist in the project")); + errors.add(new BatchTestCaseOperationError(testCaseId, "Test case does not exist in the project", + BatchTestCaseOperationErrorReason.NOT_FOUND)); + continue; + } + + // Only Ready test cases can be added to a launch + if (testCase.getStatus() != TmsTestCaseStatus.READY) { + log.warn("Test case {} is not Ready (status: {}), skipping", testCaseId, testCase.getStatus()); + errors.add(new BatchTestCaseOperationError(testCaseId, + "Test case is not Ready and cannot be added to a launch", + BatchTestCaseOperationErrorReason.NOT_READY)); continue; } @@ -436,7 +451,13 @@ public BatchTestCaseOperationResultRS addTestCasesToLaunch(long projectId, Launc public void addTestCaseToLaunch(long projectId, Launch launch, Long testCaseId) { log.debug("Adding {} test case to launch: {}", testCaseId, launch.getId()); - createExecution(projectId, tmsTestCaseService.getById(projectId, testCaseId, false), launch); + var testCase = tmsTestCaseService.getById(projectId, testCaseId, false); + if (testCase.getStatus() != TmsTestCaseStatus.READY) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Test case " + testCaseId + " is not Ready and cannot be added to a launch"); + } + + createExecution(projectId, testCase, launch); log.debug("Added {} test case to launch: {}", testCaseId, launch.getId()); } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityService.java new file mode 100644 index 0000000000..023740f164 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityService.java @@ -0,0 +1,51 @@ +package com.epam.reportportal.base.core.tms.service; + +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationMetadataRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseMetricsRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * Persists and computes the AI quality score / generation cost data a test + * case version carries, per the project's {@code TmsQualityStandard}. + */ +public interface TmsTestCaseQualityService { + + /** + * {@code true} when the request carries AI-shaped data (quality scores or + * generation metadata) — the signal used to infer {@code origin = AI} on + * create, since the client never sets {@code origin} directly. + */ + boolean hasAiSignal(List qualityScores, TmsTestCaseGenerationMetadataRQ generation); + + /** + * Replaces the quality scores recorded for a version. No-op when {@code + * qualityScores} is null (patch semantics: absent means "leave as is"). + * + * @param projectId project the criteria must belong to + */ + void applyQualityScores(Long projectId, TmsTestCaseVersion version, List qualityScores); + + /** + * Replaces the generation metadata recorded for a version. No-op when + * {@code generation} is null. + */ + void applyGenerationMetadata(TmsTestCaseVersion version, TmsTestCaseGenerationMetadataRQ generation); + + /** + * Builds the read-only {@code metrics} block for a version: {@code null} if + * the version has neither quality scores nor generation metadata recorded. + */ + TmsTestCaseMetricsRS buildMetrics(TmsTestCaseVersion version); + + /** + * Batch form of {@link #buildMetrics(TmsTestCaseVersion)} — fetches scores and + * generation metadata for all given versions in two queries total instead of + * two per version, keyed by {@code TmsTestCaseVersion.id}. A version with + * neither scores nor generation metadata is absent from the result map. + */ + Map buildMetricsBatch(Collection versions); +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImpl.java new file mode 100644 index 0000000000..cbc7568a84 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImpl.java @@ -0,0 +1,196 @@ +package com.epam.reportportal.base.core.tms.service; + +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationMetadataRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseMetricsRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsQualityStandardCriterionRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseGenerationMetadataRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseQualityScoreRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandardCriterion; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseGenerationMetadata; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseQualityScore; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TmsTestCaseQualityServiceImpl implements TmsTestCaseQualityService { + + private final TmsTestCaseQualityScoreRepository tmsTestCaseQualityScoreRepository; + private final TmsTestCaseGenerationMetadataRepository tmsTestCaseGenerationMetadataRepository; + private final TmsQualityStandardCriterionRepository tmsQualityStandardCriterionRepository; + + @Override + public boolean hasAiSignal(List qualityScores, TmsTestCaseGenerationMetadataRQ generation) { + return CollectionUtils.isNotEmpty(qualityScores) || generation != null; + } + + @Override + public void applyQualityScores(Long projectId, TmsTestCaseVersion version, List qualityScores) { + if (qualityScores == null) { + return; + } + var resolved = qualityScores.stream() + .map(rq -> Map.entry(resolveCriterion(projectId, rq), rq)) + .toList(); + validateNoDuplicateCriteria(resolved.stream().map(entry -> entry.getKey().getId()).toList()); + tmsTestCaseQualityScoreRepository.deleteByTestCaseVersionId(version.getId()); + var now = Instant.now(); + var scores = resolved.stream() + .map(entry -> { + var criterion = entry.getKey(); + var rq = entry.getValue(); + validateScoreInRange(rq, criterion); + var score = new TmsTestCaseQualityScore(); + score.setTestCaseVersion(version); + score.setCriterion(criterion); + score.setScore(rq.getScore()); + score.setEvaluatedAt(now); + return score; + }) + .collect(Collectors.toList()); + tmsTestCaseQualityScoreRepository.saveAll(scores); + } + + /** + * A score names its criterion either by id or by the criterion's stable key - exactly one. + */ + private TmsQualityStandardCriterion resolveCriterion(Long projectId, TmsTestCaseQualityScoreRQ rq) { + var hasId = rq.getCriterionId() != null; + var hasKey = rq.getCriterionKey() != null && !rq.getCriterionKey().isBlank(); + if (hasId == hasKey) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Exactly one of 'criterionId' and 'criterionKey' must be set for each quality score"); + } + if (hasId) { + return tmsQualityStandardCriterionRepository + .findByIdAndStandard_ProjectId(rq.getCriterionId(), projectId) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Quality standard criterion '" + rq.getCriterionId() + "' for project '" + projectId + "'")); + } + return tmsQualityStandardCriterionRepository + .findByKeyAndStandard_ProjectId(rq.getCriterionKey(), projectId) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Quality standard criterion with key '" + rq.getCriterionKey() + "' for project '" + projectId + "'")); + } + + private void validateNoDuplicateCriteria(List criterionIds) { + var duplicateCriterionIds = criterionIds.stream() + .collect(Collectors.groupingBy(id -> id, Collectors.counting())) + .entrySet().stream() + .filter(entry -> entry.getValue() > 1) + .map(Map.Entry::getKey) + .toList(); + if (!duplicateCriterionIds.isEmpty()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Duplicate criteria in qualityScores: " + duplicateCriterionIds); + } + } + + private void validateScoreInRange(TmsTestCaseQualityScoreRQ rq, TmsQualityStandardCriterion criterion) { + if (rq.getScore() < 0 || rq.getScore() > criterion.getMaxPoints()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "Score " + rq.getScore() + " for criterion '" + criterion.getId() + + "' must be within [0, " + criterion.getMaxPoints() + "]"); + } + } + + @Override + public void applyGenerationMetadata(TmsTestCaseVersion version, TmsTestCaseGenerationMetadataRQ generation) { + if (generation == null) { + return; + } + var metadata = tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionId(version.getId()) + .orElseGet(TmsTestCaseGenerationMetadata::new); + metadata.setTestCaseVersion(version); + metadata.setTokensIn(generation.getTokensIn()); + metadata.setTokensOut(generation.getTokensOut()); + metadata.setModel(generation.getModel()); + metadata.setSkill(generation.getSkill()); + metadata.setCostUsd(generation.getCostUsd()); + tmsTestCaseGenerationMetadataRepository.save(metadata); + } + + @Override + public TmsTestCaseMetricsRS buildMetrics(TmsTestCaseVersion version) { + if (version == null) { + return null; + } + return buildMetricsBatch(List.of(version)).get(version.getId()); + } + + @Override + public Map buildMetricsBatch(Collection versions) { + var versionsById = versions.stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap(TmsTestCaseVersion::getId, Function.identity(), (first, second) -> first)); + if (versionsById.isEmpty()) { + return Map.of(); + } + + var versionIds = versionsById.keySet(); + var scoresByVersionId = tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(versionIds).stream() + .collect(Collectors.groupingBy(score -> score.getTestCaseVersion().getId())); + var generationByVersionId = tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(versionIds).stream() + .collect(Collectors.toMap(metadata -> metadata.getTestCaseVersion().getId(), Function.identity())); + + var result = new HashMap(); + for (var versionId : versionIds) { + var metrics = buildMetrics(versionsById.get(versionId), + scoresByVersionId.getOrDefault(versionId, List.of()), + generationByVersionId.get(versionId)); + if (metrics != null) { + result.put(versionId, metrics); + } + } + return result; + } + + private TmsTestCaseMetricsRS buildMetrics(TmsTestCaseVersion version, List scores, + TmsTestCaseGenerationMetadata generation) { + if (scores.isEmpty() && generation == null) { + return null; + } + + var builder = TmsTestCaseMetricsRS.builder(); + if (!scores.isEmpty()) { + var overallScore = scores.stream().mapToInt(TmsTestCaseQualityScore::getScore).sum(); + var evaluatedAt = scores.stream().map(TmsTestCaseQualityScore::getEvaluatedAt) + .max(Instant::compareTo).orElse(null); + var criteria = scores.stream() + .map(score -> TmsTestCaseMetricsRS.CriterionScore.builder() + .criterionId(score.getCriterion().getId()) + .name(score.getCriterion().getName()) + .maxPoints(score.getCriterion().getMaxPoints()) + .score(score.getScore()) + .build()) + .collect(Collectors.toList()); + var obsolete = evaluatedAt != null && version.getUpdatedAt() != null + && version.getUpdatedAt().isAfter(evaluatedAt); + builder.overallScore(overallScore) + .evaluatedAt(evaluatedAt == null ? null : evaluatedAt.toEpochMilli()) + .obsolete(obsolete) + .criteria(criteria); + } + if (generation != null) { + builder.tokensIn(generation.getTokensIn()) + .tokensOut(generation.getTokensOut()) + .model(generation.getModel()) + .skill(generation.getSkill()) + .costUsd(generation.getCostUsd()); + } + return builder.build(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseService.java index 7a7201747f..0c42103835 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseService.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseService.java @@ -1,5 +1,6 @@ package com.epam.reportportal.base.core.tms.service; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseInTestPlanRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; @@ -21,6 +22,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; +import java.util.Collection; import java.util.List; import java.util.Map; import org.springframework.data.domain.Pageable; @@ -53,6 +55,20 @@ TmsTestCaseRS update(MembershipDetails membershipDetails, TmsTestCaseRS patch(MembershipDetails membershipDetails, ReportPortalUser user, Long testCaseId, TmsTestCaseRQ tmsTestCaseRQ); + /** + * Records AI quality scores and/or generation cost for a test case's current default + * version, in a dedicated call separate from editing its content + * ({@link #create}/{@link #update}/{@link #patch}). This is the AI signal: submitting it + * ratchets {@code origin MANUAL -> AI} (never back) and, the first time that happens, + * defaults {@code status} to {@code DRAFT}. + * + * @param projectId project the test case must belong to + * @param testCaseId the test case to record scores/generation for + * @param rq quality scores (full replace) and/or generation metadata + * @return the test case, reflecting the (possibly ratcheted) origin/status and new metrics + */ + TmsTestCaseRS applyGeneration(Long projectId, Long testCaseId, @Valid TmsTestCaseGenerationRQ rq); + void delete(MembershipDetails membershipDetails, ReportPortalUser user, Long testCaseId); @@ -100,6 +116,12 @@ void exportToFile(Long projectId, List ids, String format, boolean include @Transactional(readOnly = true) List getExistingTestCaseIds(Long projectId, List testCaseIds); + /** + * Ids among {@code testCaseIds} that exist in the project and are {@code READY}. + */ + @Transactional(readOnly = true) + List getReadyTestCaseIds(Long projectId, List testCaseIds); + /** * Verifies if test cases exist by provided ids in the project * @@ -108,6 +130,44 @@ void exportToFile(Long projectId, List ids, String format, boolean include */ void validateTestCasesExist(Long projectId, @NotEmpty List testCaseIds); + /** + * Server-computed status assignment for Pipeline Auto-Ready — not exposed via any + * client-facing endpoint. Promote-only: sets {@code READY} on an AI-origin {@code DRAFT} test + * case whose current default-version quality score meets {@code threshold}. Never demotes, and + * never touches manual or already-Ready cases. No-op if the test case doesn't exist, or has no + * usable score yet (never scored, or scored but obsolete since the scenario changed) — + * Auto-Ready only ever acts on a score that reflects current content. + * + * @param projectId project the test case must belong to + * @param testCaseId the test case to (re-)evaluate + * @param threshold the pipeline's {@code autoReadyThreshold} (0-100) + */ + void applyAutoReady(Long projectId, Long testCaseId, int threshold); + + /** + * Batched form of {@link #applyAutoReady(Long, Long, int)} — takes the already-loaded entities + * (no per-id re-fetch) and evaluates/saves all of them via the existing batch helpers + * ({@code TmsTestCaseVersionService.getDefaultVersions}, {@code TmsTestCaseQualityService.buildMetricsBatch}) + * instead of one round-trip per test case. + * + * @param projectId project the test cases must belong to + * @param testCases already-loaded test case entities to (re-)evaluate + * @param threshold the pipeline's {@code autoReadyThreshold} (0-100) + * @return how many test cases were promoted to {@code READY} + */ + int applyAutoReadyBatch(Long projectId, Collection testCases, int threshold); + + /** + * Bulk lookup of test case entities by their {@code displayId} within a project — used by + * callers (e.g. Pipeline ingest) that need the entities themselves, not the response DTO, so + * they don't have to depend on the repository directly. + * + * @param projectId project the test cases must belong to + * @param displayIds display ids to resolve + * @return the test cases found (unresolvable display ids are silently omitted) + */ + List getEntitiesByDisplayIds(Long projectId, Collection displayIds); + /** * Duplicates multiple test cases with all their related data. * diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImpl.java index 11d196a032..ff8c666d91 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImpl.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImpl.java @@ -9,9 +9,12 @@ import com.epam.reportportal.base.core.tms.dto.NewTestFolderRQ; import com.epam.reportportal.base.core.tms.dto.PreparedTestCase; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseAttributeImportRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseAttributeRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseImportParseResult; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseImportRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseInTestPlanRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseMetricsRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCasePreparationForImportResult; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; @@ -25,6 +28,7 @@ import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationError; import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationResultRS; import com.epam.reportportal.base.core.tms.mapper.TmsTestCaseActivityResourceMapper; +import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; import com.epam.reportportal.base.core.tms.mapper.TmsTestCaseMapper; import com.epam.reportportal.base.core.tms.mapper.factory.TmsTestCaseExporterFactory; import com.epam.reportportal.base.core.tms.mapper.factory.TmsTestCaseImporterFactory; @@ -40,6 +44,8 @@ import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseExecution; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestFolder; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; import com.epam.reportportal.base.model.Page; @@ -49,6 +55,7 @@ import jakarta.validation.Valid; import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -92,6 +99,7 @@ public class TmsTestCaseServiceImpl implements TmsTestCaseService { private final TmsTestPlanTestCaseRepository tmsTestPlanTestCaseRepository; private final ApplicationEventPublisher eventPublisher; private final TmsTestCaseActivityResourceMapper tmsTestCaseActivityResourceMapper; + private final TmsTestCaseQualityService tmsTestCaseQualityService; private TmsTestFolderService tmsTestFolderService; private TmsTestCaseExecutionService tmsTestCaseExecutionService; @@ -147,12 +155,105 @@ public TmsTestCaseRS getById(long projectId, Long testCaseId, boolean includeLas ); var defaultVersion = tmsTestCaseVersionService.getDefaultVersion(testCaseId); if (!includeLastExecution) { - return tmsTestCaseMapper.convert(testCase, defaultVersion); + return tmsTestCaseMapper.convert(testCase, defaultVersion, + tmsTestCaseQualityService.buildMetrics(defaultVersion)); } return tmsTestCaseMapper.convert( testCase, defaultVersion, - tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)); + tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId), + tmsTestCaseQualityService.buildMetrics(defaultVersion)); + } + + @Override + @Transactional + public TmsTestCaseRS applyGeneration(Long projectId, Long testCaseId, TmsTestCaseGenerationRQ rq) { + var testCase = tmsTestCaseRepository + .findByProjectIdAndId(projectId, testCaseId) + .orElseThrow(() -> new ReportPortalException( + NOT_FOUND, TEST_CASE_NOT_FOUND_BY_ID.formatted(testCaseId, projectId))); + // Resolved inside the same transaction as the writes below, so a concurrent content edit + // can't create a newer default version between this lookup and the writes that follow. + var defaultVersion = tmsTestCaseVersionService.getDefaultVersion(testCaseId); + + tmsTestCaseQualityService.applyQualityScores(projectId, defaultVersion, rq.getQualityScores()); + tmsTestCaseQualityService.applyGenerationMetadata(defaultVersion, rq.getGeneration()); + + // origin only ratchets MANUAL -> AI, never back; this endpoint IS the AI signal, so there's + // no "explicit status override" input here (unlike create/patch) - it always defaults DRAFT. + if (testCase.getOrigin() == TmsTestCaseOrigin.MANUAL + && tmsTestCaseQualityService.hasAiSignal(rq.getQualityScores(), rq.getGeneration())) { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.DRAFT); + tmsTestCaseRepository.save(testCase); + publishLifecycleChanged(List.of(testCase.getId())); + } + + return tmsTestCaseMapper.convert( + testCase, defaultVersion, + tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId), + tmsTestCaseQualityService.buildMetrics(defaultVersion)); + } + + @Override + @Transactional + public void applyAutoReady(Long projectId, Long testCaseId, int threshold) { + var testCase = tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId).orElse(null); + if (testCase == null || !isAutoReadyCandidate(testCase)) { + return; + } + var defaultVersion = tmsTestCaseVersionService.getDefaultVersion(testCaseId); + var metrics = tmsTestCaseQualityService.buildMetrics(defaultVersion); + if (metrics == null || metrics.getOverallScore() == null || metrics.isObsolete() + || metrics.getOverallScore() < threshold) { + return; + } + testCase.setStatus(TmsTestCaseStatus.READY); + tmsTestCaseRepository.save(testCase); + publishLifecycleChanged(List.of(testCaseId)); + } + + /** + * Auto-Ready only promotes: an AI-generated case still waiting for review. Manual cases and + * cases that are already Ready (e.g. approved by a human) are never touched. + */ + private boolean isAutoReadyCandidate(TmsTestCase testCase) { + return testCase.getOrigin() == TmsTestCaseOrigin.AI && testCase.getStatus() == TmsTestCaseStatus.DRAFT; + } + + @Override + @Transactional + public int applyAutoReadyBatch(Long projectId, Collection testCases, int threshold) { + var candidates = testCases.stream().filter(this::isAutoReadyCandidate).toList(); + if (candidates.isEmpty()) { + return 0; + } + var testCaseIds = candidates.stream().map(TmsTestCase::getId).toList(); + var defaultVersionsByTestCaseId = tmsTestCaseVersionService.getDefaultVersions(testCaseIds); + var metricsByVersionId = tmsTestCaseQualityService.buildMetricsBatch(defaultVersionsByTestCaseId.values()); + + var toSave = new ArrayList(); + for (var testCase : candidates) { + var defaultVersion = defaultVersionsByTestCaseId.get(testCase.getId()); + var metrics = defaultVersion == null ? null : metricsByVersionId.get(defaultVersion.getId()); + if (metrics == null || metrics.getOverallScore() == null || metrics.isObsolete() + || metrics.getOverallScore() < threshold) { + continue; + } + testCase.setStatus(TmsTestCaseStatus.READY); + toSave.add(testCase); + } + if (!toSave.isEmpty()) { + tmsTestCaseRepository.saveAll(toSave); + publishLifecycleChanged(toSave.stream().map(TmsTestCase::getId).toList()); + } + return toSave.size(); + } + + @Override + @Transactional(readOnly = true) + public List getEntitiesByDisplayIds(Long projectId, Collection displayIds) { + return tmsTestCaseRepository.findByProjectIdAndDisplayIdIn(projectId, displayIds); } @Override @@ -168,6 +269,11 @@ public TmsTestCaseRS create(MembershipDetails membershipDetails, tmsTestCaseRQ.getTestFolder() ) ); + // A manual create always starts READY; an explicit status still overrides that default. + // origin/status for AI-authored cases is set separately via POST .../generation. + if (tmsTestCaseRQ.getStatus() != null) { + tmsTestCase.setStatus(TmsTestCaseStatus.valueOf(tmsTestCaseRQ.getStatus().name())); + } tmsTestCaseRepository.save(tmsTestCase); @@ -182,7 +288,7 @@ public TmsTestCaseRS create(MembershipDetails membershipDetails, publishTestCaseCreatedEvent(membershipDetails, user, tmsTestCase, defaultVersion); - return tmsTestCaseMapper.convert(tmsTestCase, defaultVersion); + return tmsTestCaseMapper.convert(tmsTestCase, defaultVersion, tmsTestCaseQualityService.buildMetrics(defaultVersion)); } @Override @@ -217,7 +323,7 @@ public TmsTestCaseRS update(MembershipDetails membershipDetails, var after = tmsTestCaseActivityResourceMapper.buildActivityResource(existingTestCase, defaultVersion); - + tmsTestCaseActivityResourceMapper .buildTestCaseFieldChangedEvents( membershipDetails, @@ -225,9 +331,11 @@ public TmsTestCaseRS update(MembershipDetails membershipDetails, before, after) .forEach(eventPublisher::publishEvent); - + publishLifecycleChanged(List.of(existingTestCase.getId())); + return tmsTestCaseMapper.convert( - existingTestCase, defaultVersion, lastTestCaseExecution + existingTestCase, defaultVersion, lastTestCaseExecution, + tmsTestCaseQualityService.buildMetrics(defaultVersion) ); }) .orElseGet(() -> create(membershipDetails, user, tmsTestCaseRQ)); @@ -272,9 +380,11 @@ public TmsTestCaseRS patch(MembershipDetails membershipDetails, before, after) .forEach(eventPublisher::publishEvent); - + publishLifecycleChanged(List.of(existingTestCase.getId())); + return tmsTestCaseMapper.convert( - existingTestCase, defaultVersion, lastTestCaseExecution + existingTestCase, defaultVersion, lastTestCaseExecution, + tmsTestCaseQualityService.buildMetrics(defaultVersion) ); }) .orElseThrow(() -> new ReportPortalException( @@ -285,6 +395,7 @@ public TmsTestCaseRS patch(MembershipDetails membershipDetails, @Override @Transactional public void delete(MembershipDetails membershipDetails, ReportPortalUser user, Long testCaseId) { + publishLifecycleChanged(List.of(testCaseId)); tmsTestCaseAttributeService.deleteAllByTestCaseId(testCaseId); tmsTestCaseVersionService.deleteAllByTestCaseId(testCaseId); tmsTestPlanTestCaseRepository.deleteAllByTestCaseId(testCaseId); @@ -303,6 +414,7 @@ public void deleteByTestFolderId(MembershipDetails membershipDetails, var testCaseIds = Optional .ofNullable(tmsTestCaseRepository.findIdsByProjectIdAndTestFolderId(projectId, folderId)) .orElseGet(ArrayList::new); + publishLifecycleChanged(testCaseIds); tmsTestCaseAttributeService.deleteAllByTestFolderId(projectId, folderId); tmsTestCaseVersionService.deleteAllByTestFolderId(projectId, folderId); tmsTestPlanTestCaseRepository.deleteAllByTestFolderId(projectId, folderId); @@ -319,6 +431,7 @@ public void delete(MembershipDetails membershipDetails, ReportPortalUser user, var testCaseIds = Optional .ofNullable(tmsTestCaseRepository.findIdsByProjectId(membershipDetails.getProjectId())) .orElseGet(ArrayList::new); + publishLifecycleChanged(deleteRequest.getTestCaseIds()); tmsTestCaseAttributeService.deleteAllByTestCaseIds(deleteRequest.getTestCaseIds()); tmsTestCaseVersionService.deleteAllByTestCaseIds(deleteRequest.getTestCaseIds()); tmsTestPlanTestCaseRepository.deleteAllByTestCaseIds(deleteRequest.getTestCaseIds()); @@ -631,11 +744,13 @@ public Page getTestCasesByCriteria(long projectId, Filter filter, var lastTestCasesExecutions = tmsTestCaseExecutionService.getLastTestCasesExecutionsByTestCaseIds( testCaseIds.getContent() ); + var metricsByVersionId = tmsTestCaseQualityService.buildMetricsBatch(testCaseDefaultVersions.values()); var page = tmsTestCaseMapper.convert( orderedTestTestCases, testCaseDefaultVersions, lastTestCasesExecutions, + metricsByVersionId, pageable, testCaseIds.getTotalElements() ); @@ -806,7 +921,8 @@ public TmsTestCaseRS duplicateTestCase(MembershipDetails membershipDetails, ? tmsTestFolderService.getEntityById(projectId, targetFolderId) : null; var duplicated = duplicateTestCaseInternal(membershipDetails, user, projectId, testCaseId, targetFolder); - return tmsTestCaseMapper.convert(duplicated.testCase(), duplicated.defaultVersion()); + return tmsTestCaseMapper.convert(duplicated.testCase(), duplicated.defaultVersion(), + tmsTestCaseQualityService.buildMetrics(duplicated.defaultVersion())); } private record DuplicatedTestCase(TmsTestCase testCase, TmsTestCaseVersion defaultVersion) {} @@ -865,6 +981,15 @@ public List getExistingTestCaseIds(Long projectId, List testCaseIds) return tmsTestCaseRepository.findExistingTestCaseIds(projectId, testCaseIds); } + @Override + @Transactional(readOnly = true) + public List getReadyTestCaseIds(Long projectId, List testCaseIds) { + if (testCaseIds == null || testCaseIds.isEmpty()) { + return List.of(); + } + return tmsTestCaseRepository.findIdsByProjectIdAndIdsAndStatus(projectId, testCaseIds, TmsTestCaseStatus.READY); + } + @Override @Transactional(readOnly = true) public Page getTestCasesInTestPlan(Long projectId, Long testPlanId, @@ -1005,10 +1130,11 @@ public Map getByIdsMap(long projectId, List testCaseI var lastExecutions = includeLastExecution ? tmsTestCaseExecutionService.getLastTestCasesExecutionsByTestCaseIds(testCaseIds) : Collections.emptyMap(); + var metricsByVersionId = tmsTestCaseQualityService.buildMetricsBatch(defaultVersions.values()); return testCases.stream().collect(Collectors.toMap( TmsTestCase::getId, - testCase -> toTestCaseResource(testCase, defaultVersions, lastExecutions, + testCase -> toTestCaseResource(testCase, defaultVersions, lastExecutions, metricsByVersionId, includeLastExecution), (first, ignored) -> first )); @@ -1090,10 +1216,16 @@ private TmsTestCaseRS toTestCaseResource( TmsTestCase testCase, Map defaultVersions, Map lastExecutions, + Map metricsByVersionId, boolean includeLastExecution) { var defaultVersion = defaultVersions.get(testCase.getId()); + var metrics = defaultVersion == null ? null : metricsByVersionId.get(defaultVersion.getId()); return includeLastExecution - ? tmsTestCaseMapper.convert(testCase, defaultVersion, lastExecutions.get(testCase.getId())) - : tmsTestCaseMapper.convert(testCase, defaultVersion); + ? tmsTestCaseMapper.convert(testCase, defaultVersion, lastExecutions.get(testCase.getId()), metrics) + : tmsTestCaseMapper.convert(testCase, defaultVersion, metrics); + } + + private void publishLifecycleChanged(Collection testCaseIds) { + eventPublisher.publishEvent(new TmsTestCasesLifecycleChangedEvent(testCaseIds)); } } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionServiceImpl.java index f5a03ee12e..5090d23066 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionServiceImpl.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionServiceImpl.java @@ -10,6 +10,7 @@ import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; import jakarta.validation.Valid; +import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; @@ -105,6 +106,7 @@ public TmsTestCaseVersion updateDefaultTestCaseVersion(long projectId, TmsTestCa tmsManualScenarioRQ); existingDefaultVersion.setManualScenario(updatedManualScenario); + existingDefaultVersion.setUpdatedAt(Instant.now()); tmsTestCaseVersionRepository.save(existingDefaultVersion); } @@ -125,6 +127,7 @@ public TmsTestCaseVersion patchDefaultTestCaseVersion(long projectId, TmsTestCas .patchTmsManualScenario(projectId, existingDefaultVersion, tmsManualScenarioRQ); existingDefaultVersion.setManualScenario(patchedManualScenario); + existingDefaultVersion.setUpdatedAt(Instant.now()); return tmsTestCaseVersionRepository.save(existingDefaultVersion); } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImpl.java index 3b24a7a5f0..16ead7dec7 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImpl.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImpl.java @@ -9,6 +9,7 @@ import com.epam.reportportal.base.core.tms.dto.TmsTestPlanRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestPlanRS; import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationError; +import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationErrorReason; import com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationResultRS; import com.epam.reportportal.base.core.tms.mapper.TmsTestPlanMapper; import com.epam.reportportal.base.infrastructure.persistence.commons.ReportPortalUser; @@ -183,6 +184,8 @@ public BatchTestCaseOperationResultRS addTestCasesToPlan(Long projectId, Long te tmsTestCaseService.getExistingTestCaseIds(projectId, testCaseIds) ); + var readyTestCaseIds = new HashSet<>(tmsTestCaseService.getReadyTestCaseIds(projectId, testCaseIds)); + // Get test case IDs already added to the plan var testCaseIdsAreInTestPlan = new HashSet<>( tmsTestPlanTestCaseRepository.findTestCaseIdsByTestPlanId(testPlanId) @@ -193,7 +196,8 @@ public BatchTestCaseOperationResultRS addTestCasesToPlan(Long projectId, Long te // Check if a test case exists if (!existingTestCaseIds.contains(testCaseId)) { errors.add(new BatchTestCaseOperationError(testCaseId, - String.format("Test case with id %s not found", testCaseId))); + String.format("Test case with id %s not found", testCaseId), + BatchTestCaseOperationErrorReason.NOT_FOUND)); continue; } @@ -201,11 +205,20 @@ public BatchTestCaseOperationResultRS addTestCasesToPlan(Long projectId, Long te if (testCaseIdsAreInTestPlan.contains(testCaseId)) { errors.add(new BatchTestCaseOperationError( testCaseId, - String.format("Test case with id %s already exists in test plan", testCaseId) + String.format("Test case with id %s already exists in test plan", testCaseId), + BatchTestCaseOperationErrorReason.ALREADY_EXISTS )); continue; } + // Only Ready test cases can be planned, same rule as adding to a launch + if (!readyTestCaseIds.contains(testCaseId)) { + errors.add(new BatchTestCaseOperationError(testCaseId, + "Test case is not Ready and cannot be added to a test plan", + BatchTestCaseOperationErrorReason.NOT_READY)); + continue; + } + // Try to add a test case if (addTestCaseToTestPlan(testPlanId, testCaseId)) { successCount++; diff --git a/src/main/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidator.java b/src/main/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidator.java new file mode 100644 index 0000000000..237569e6d0 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidator.java @@ -0,0 +1,22 @@ +package com.epam.reportportal.base.core.tms.validation; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for {@link ValidQualityStandardRQ}. + */ +public class QualityStandardRQValidator implements ConstraintValidator { + + @Override + public boolean isValid(TmsQualityStandardRQ value, ConstraintValidatorContext context) { + if (value == null || value.getCriteria() == null) { + return true; // let @NotEmpty/@NotNull handle that + } + long sum = value.getCriteria().stream() + .mapToLong(c -> c.getMaxPoints() == null ? 0 : c.getMaxPoints()) + .sum(); + return sum == 100; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/validation/ValidQualityStandardRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/validation/ValidQualityStandardRQ.java new file mode 100644 index 0000000000..55a790c542 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/validation/ValidQualityStandardRQ.java @@ -0,0 +1,25 @@ +package com.epam.reportportal.base.core.tms.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Validates that a {@code TmsQualityStandardRQ}'s criteria max points sum to 100. + */ +@Documented +@Constraint(validatedBy = QualityStandardRQValidator.class) +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidQualityStandardRQ { + + String message() default "Criteria max points must sum to 100"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/com/epam/reportportal/base/exception/handler/GlobalExceptionHandler.java b/src/main/java/com/epam/reportportal/base/exception/handler/GlobalExceptionHandler.java index 7c9ea9c607..a08b58c202 100644 --- a/src/main/java/com/epam/reportportal/base/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/epam/reportportal/base/exception/handler/GlobalExceptionHandler.java @@ -49,6 +49,7 @@ @RestControllerAdvice(value = { "com.epam.reportportal.base.ws.controller", "com.epam.reportportal.base.core.tms.controller", + "com.epam.reportportal.base.core.aifactory.controller", "com.epam.reportportal.base.reporting.async.controller" }) @Log4j2 diff --git a/src/main/java/com/epam/reportportal/base/exception/rest/StatusCodeMapping.java b/src/main/java/com/epam/reportportal/base/exception/rest/StatusCodeMapping.java index b138a2bb9c..4b5bb6b98d 100644 --- a/src/main/java/com/epam/reportportal/base/exception/rest/StatusCodeMapping.java +++ b/src/main/java/com/epam/reportportal/base/exception/rest/StatusCodeMapping.java @@ -67,6 +67,8 @@ private StatusCodeMapping() { put(ErrorType.PROJECT_ALREADY_EXISTS, HttpStatus.CONFLICT); put(ErrorType.USER_ALREADY_EXISTS, HttpStatus.CONFLICT); put(ErrorType.USER_ALREADY_ASSIGNED, HttpStatus.CONFLICT); + put(ErrorType.PIPELINE_STAGE_RETRY_CONFLICT, HttpStatus.CONFLICT); + put(ErrorType.IDEMPOTENCY_KEY_CONFLICT, HttpStatus.CONFLICT); put(ErrorType.USER_FILTER_ALREADY_EXISTS, HttpStatus.CONFLICT); put(ErrorType.ROLE_ALREADY_EXISTS_ERROR, HttpStatus.CONFLICT); diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterTarget.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterTarget.java index 24499c170c..a3bd5d41ed 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterTarget.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterTarget.java @@ -153,10 +153,12 @@ import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_FOLDER_ID; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_ID; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_NAME; +import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_ORIGIN; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_PLAN_ID; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_PRIORITY; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_PROJECT_ID; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_SEARCH; +import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_STATUS; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_UPDATED_AT; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseExecutionCriteriaConstant.CRITERIA_TMS_TEST_CASE_EXECUTION_DISPLAY_ID; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseExecutionCriteriaConstant.CRITERIA_TMS_TEST_CASE_EXECUTION_ID; @@ -1902,6 +1904,10 @@ protected Field idField() { String.class).get(), new CriteriaHolderBuilder().newBuilder(CRITERIA_TMS_TEST_CASE_DISPLAY_ID, TMS_TEST_CASE.DISPLAY_ID, String.class).get(), + new CriteriaHolderBuilder().newBuilder(CRITERIA_TMS_TEST_CASE_STATUS, TMS_TEST_CASE.STATUS, + String.class).get(), + new CriteriaHolderBuilder().newBuilder(CRITERIA_TMS_TEST_CASE_ORIGIN, TMS_TEST_CASE.ORIGIN, + String.class).get(), new CriteriaHolderBuilder().newBuilder(CRITERIA_TMS_TEST_CASE_CREATED_AT, TMS_TEST_CASE.CREATED_AT, Instant.class).get(), new CriteriaHolderBuilder().newBuilder(CRITERIA_TMS_TEST_CASE_UPDATED_AT, TMS_TEST_CASE.UPDATED_AT, @@ -1969,6 +1975,8 @@ protected Collection selectFields() { TMS_TEST_CASE.PRIORITY, TMS_TEST_CASE.EXTERNAL_ID, TMS_TEST_CASE.DISPLAY_ID, + TMS_TEST_CASE.STATUS, + TMS_TEST_CASE.ORIGIN, TMS_TEST_CASE.CREATED_AT, TMS_TEST_CASE.UPDATED_AT, TMS_TEST_CASE.TEST_FOLDER_ID diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/constant/tms/TmsTestCaseCriteriaConstant.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/constant/tms/TmsTestCaseCriteriaConstant.java index 08bbccf1e5..24d02b246e 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/constant/tms/TmsTestCaseCriteriaConstant.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/constant/tms/TmsTestCaseCriteriaConstant.java @@ -24,6 +24,8 @@ public final class TmsTestCaseCriteriaConstant { public static final String CRITERIA_TMS_TEST_CASE_PRIORITY = "priority"; public static final String CRITERIA_TMS_TEST_CASE_EXTERNAL_ID = "externalId"; public static final String CRITERIA_TMS_TEST_CASE_DISPLAY_ID = "displayId"; + public static final String CRITERIA_TMS_TEST_CASE_STATUS = "status"; + public static final String CRITERIA_TMS_TEST_CASE_ORIGIN = "origin"; public static final String CRITERIA_TMS_TEST_CASE_CREATED_AT = "createdAt"; public static final String CRITERIA_TMS_TEST_CASE_UPDATED_AT = "updatedAt"; public static final String CRITERIA_TMS_TEST_CASE_PROJECT_ID = "projectId"; diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineIterationRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineIterationRepository.java new file mode 100644 index 0000000000..aa064d5acb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineIterationRepository.java @@ -0,0 +1,44 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PipelineIterationRepository extends JpaRepository, + JpaSpecificationExecutor { + + long countByPipelineId(Long pipelineId); + + /** + * Locks the iteration row for the rest of the caller's transaction, serializing concurrent + * reports into the same iteration. + */ + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select it from PipelineIteration it where it.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + Optional findFirstByPipelineIdOrderByIterationNumberDesc(Long pipelineId); + + Optional findByPipelineIdAndIdempotencyKey(Long pipelineId, String idempotencyKey); + + /** + * Batched form of {@link #countByPipelineId(Long)} — one {@code GROUP BY} query for the whole + * page instead of one query per pipeline. + */ + @Query("SELECT it.pipeline.id AS pipelineId, COUNT(it) AS count FROM PipelineIteration it " + + "WHERE it.pipeline.id IN :pipelineIds GROUP BY it.pipeline.id") + List countByPipelineIdIn(@Param("pipelineIds") Collection pipelineIds); + + interface PipelineIterationCount { + Long getPipelineId(); + + Long getCount(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineRepository.java new file mode 100644 index 0000000000..d073fd5822 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineRepository.java @@ -0,0 +1,26 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PipelineRepository extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByProjectIdAndName(Long projectId, String name); + + /** + * Locks the pipeline row for the rest of the caller's transaction, serializing + * concurrent iteration-number assignment for the same pipeline. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from Pipeline p where p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineSpecifications.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineSpecifications.java new file mode 100644 index 0000000000..bd6ef473b3 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineSpecifications.java @@ -0,0 +1,107 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.aifactory; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.util.StringUtils; + +/** + * Search and filter predicates for the pipeline and iteration list endpoints. Iterations are searched + * through the text derived from their attributes and filtered by exact attribute through their attribute + * index, so nothing here knows which attributes exist. + */ +public final class PipelineSpecifications { + + private static final char ESCAPE = '\\'; + + private PipelineSpecifications() { + } + + /** + * Pipelines of a project that match {@code search}: by name, or because one of their iterations + * matches (see {@link #iterationMatches}). A blank search matches every pipeline. + */ + public static Specification pipelinesOfProject(Long projectId, String search) { + return (root, query, cb) -> { + var inProject = cb.equal(root.get("project").get("id"), projectId); + var term = normalize(search); + if (term == null) { + return inProject; + } + var byName = contains(cb, root.get("name"), term); + var iterations = query.subquery(Long.class); + var iteration = iterations.from(PipelineIteration.class); + iterations.select(iteration.get("id")) + .where(cb.equal(iteration.get("pipeline"), root), iterationMatches(iteration, cb, term)); + return cb.and(inProject, cb.or(byName, cb.exists(iterations))); + }; + } + + /** + * Iterations of one pipeline that satisfy every given criterion; absent criteria are skipped. + * + * @param attributes exact {@code key -> value} filters, all of which must hold + */ + public static Specification iterationsOfPipeline(Long pipelineId, String search, + Collection statuses, Map attributes) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("pipeline").get("id"), pipelineId)); + var term = normalize(search); + if (term != null) { + predicates.add(iterationMatches(root, cb, term)); + } + if (statuses != null && !statuses.isEmpty()) { + predicates.add(root.get("statusCategory").in(statuses)); + } + if (attributes != null) { + attributes.forEach((key, value) -> { + if (StringUtils.hasText(key)) { + var token = PipelineAttribute.indexToken(key, value); + predicates.add(cb.like(root.get("attributeIndex"), "%" + escapeLike(token) + "%", ESCAPE)); + } + }); + } + return cb.and(predicates.toArray(Predicate[]::new)); + }; + } + + private static Predicate iterationMatches(Root iteration, CriteriaBuilder cb, String term) { + var number = term.startsWith("#") ? term.substring(1) : term; + var predicates = new ArrayList(); + predicates.add(contains(cb, iteration.get("searchText"), term)); + parseIterationNumber(number) + .ifPresent(n -> predicates.add(cb.equal(iteration.get("iterationNumber"), n))); + return cb.or(predicates.toArray(Predicate[]::new)); + } + + private static Optional parseIterationNumber(String value) { + if (value.isEmpty() || value.length() > 9 || !value.chars().allMatch(Character::isDigit)) { + return Optional.empty(); + } + return Optional.of(Integer.parseInt(value)); + } + + private static Predicate contains(CriteriaBuilder cb, Expression column, String term) { + return cb.like(cb.lower(column), "%" + escapeLike(term.toLowerCase()) + "%", ESCAPE); + } + + private static String normalize(String search) { + return StringUtils.hasText(search) ? search.trim() : null; + } + + private static String escapeLike(String value) { + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageAttemptRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageAttemptRepository.java new file mode 100644 index 0000000000..1e50299810 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageAttemptRepository.java @@ -0,0 +1,12 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface PipelineStageAttemptRepository extends JpaRepository { + + List findByIterationIdOrderByStageKeyAscAttemptAsc(Long iterationId); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageRepository.java new file mode 100644 index 0000000000..cf9d440f04 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageRepository.java @@ -0,0 +1,53 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import jakarta.persistence.LockModeType; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PipelineStageRepository extends JpaRepository { + + List findByIterationIdOrderBySequenceAsc(Long iterationId); + + Optional findByIterationIdAndStageKey(Long iterationId, String stageKey); + + /** + * Locks the stage row for the rest of the caller's transaction, so concurrent retries of the + * same stage are serialized and the second one sees the first one's PENDING status. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select s from PipelineStage s where s.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + /** + * Batched form of {@link #findByIterationIdOrderBySequenceAsc(Long)} — one query for the whole + * page instead of one query per iteration; the caller groups the result by + * {@code stage.getIteration().getId()}. + */ + List findByIterationIdInOrderByIterationIdAscSequenceAsc(Collection iterationIds); + + long countByIterationId(Long iterationId); + + void deleteByIterationId(Long iterationId); + + /** + * Batched form of {@link #countByIterationId(Long)} — one {@code GROUP BY} query for the whole + * page instead of one query per iteration. + */ + @Query("SELECT s.iteration.id AS iterationId, COUNT(s) AS count FROM PipelineStage s " + + "WHERE s.iteration.id IN :iterationIds GROUP BY s.iteration.id") + List countByIterationIdIn(@Param("iterationIds") Collection iterationIds); + + interface PipelineStageCount { + Long getIterationId(); + + Long getCount(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/PipelineStageTestCaseRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/PipelineStageTestCaseRepository.java new file mode 100644 index 0000000000..c3ce58f773 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/PipelineStageTestCaseRepository.java @@ -0,0 +1,51 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.PipelineStageTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface PipelineStageTestCaseRepository extends JpaRepository { + + List findByStage_Iteration_Id(Long iterationId); + + void deleteByStage_Id(Long stageId); + + /** + * The test cases linked to the given stage, e.g. the ones an Upload stage created. + */ + @Query("SELECT l.testCase FROM PipelineStageTestCase l WHERE l.stage.id = :stageId") + List findTestCasesByStageId(@Param("stageId") Long stageId); + + /** + * Ids of the iterations that have any of the given test cases linked to one of their stages. + */ + @Query("SELECT DISTINCT l.stage.iteration.id FROM PipelineStageTestCase l WHERE l.testCase.id IN :testCaseIds") + List findIterationIdsByTestCaseIdIn(@Param("testCaseIds") Collection testCaseIds); + + /** + * Batched per-iteration test case counts: distinct linked test cases and how many of them are + * currently {@code READY}. Iterations without links are absent from the result. + */ + @Query("SELECT l.stage.iteration.id AS iterationId, COUNT(DISTINCT l.testCase.id) AS testCasesCount, " + + "COUNT(DISTINCT CASE WHEN l.testCase.status = :readyStatus THEN l.testCase.id END) AS readyCount " + + "FROM PipelineStageTestCase l WHERE l.stage.iteration.id IN :iterationIds " + + "GROUP BY l.stage.iteration.id") + List countTestCasesByIterationIdIn( + @Param("iterationIds") Collection iterationIds, + @Param("readyStatus") TmsTestCaseStatus readyStatus); + + interface IterationTestCaseCounts { + Long getIterationId(); + + Long getTestCasesCount(); + + Long getReadyCount(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardCriterionRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardCriterionRepository.java new file mode 100644 index 0000000000..0637c8ca27 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardCriterionRepository.java @@ -0,0 +1,17 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandardCriterion; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TmsQualityStandardCriterionRepository extends JpaRepository { + + List findByStandardIdOrderBySequenceAsc(Long standardId); + + Optional findByIdAndStandard_ProjectId(Long id, Long projectId); + + Optional findByKeyAndStandard_ProjectId(String key, Long projectId); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardRepository.java new file mode 100644 index 0000000000..4aad252cd7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardRepository.java @@ -0,0 +1,16 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandard; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TmsQualityStandardRepository extends JpaRepository { + + Optional findByProjectId(Long projectId); + + boolean existsByProjectId(Long projectId); + + void deleteByProjectId(Long projectId); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseGenerationMetadataRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseGenerationMetadataRepository.java new file mode 100644 index 0000000000..745bf08f90 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseGenerationMetadataRepository.java @@ -0,0 +1,16 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseGenerationMetadata; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TmsTestCaseGenerationMetadataRepository extends JpaRepository { + + Optional findByTestCaseVersionId(Long testCaseVersionId); + + List findByTestCaseVersionIdIn(Collection testCaseVersionIds); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseQualityScoreRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseQualityScoreRepository.java new file mode 100644 index 0000000000..26f57f2955 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseQualityScoreRepository.java @@ -0,0 +1,26 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseQualityScore; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TmsTestCaseQualityScoreRepository extends JpaRepository { + + List findByTestCaseVersionId(Long testCaseVersionId); + + @Query("select s from TmsTestCaseQualityScore s join fetch s.criterion " + + "where s.testCaseVersion.id in :testCaseVersionIds") + List findByTestCaseVersionIdIn(@Param("testCaseVersionIds") Collection testCaseVersionIds); + + void deleteByTestCaseVersionId(Long testCaseVersionId); + + boolean existsByCriterionStandardProjectId(Long projectId); + + @Query("select distinct s.criterion.id from TmsTestCaseQualityScore s where s.criterion.id in :criterionIds") + List findDistinctCriterionIdsIn(@Param("criterionIds") Collection criterionIds); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseRepository.java index 2f12113cc0..d9f8e535c7 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseRepository.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseRepository.java @@ -1,5 +1,6 @@ package com.epam.reportportal.base.infrastructure.persistence.dao.tms; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import com.epam.reportportal.base.infrastructure.persistence.dao.ReportPortalRepository; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; import java.util.Collection; @@ -33,10 +34,12 @@ public interface TmsTestCaseRepository extends ReportPortalRepository findByProjectIdAndId(@Param("projectId") Long projectId, @Param("id") Long id); - Optional findByProjectIdAndExternalId(Long projectId, String externalId); + Optional findByProjectIdAndExternalId(Long projectId, String externalId); List findByProjectIdAndExternalIdIn(Long projectId, Collection externalIds); + List findByProjectIdAndDisplayIdIn(Long projectId, Collection displayIds); + @Query("SELECT DISTINCT tc FROM TmsTestCase tc " + "LEFT JOIN FETCH tc.attributes t " + "WHERE tc.project.id = :projectId AND tc.id IN (:ids)" @@ -50,6 +53,11 @@ List findByProjectIdAndIds(@Param("projectId") Long projectId, List findExistingIdsByProjectIdAndIds(@Param("projectId") Long projectId, @Param("ids") List ids); + @Query("SELECT tc.id FROM TmsTestCase tc " + + "WHERE tc.project.id = :projectId AND tc.id IN (:ids) AND tc.status = :status") + List findIdsByProjectIdAndIdsAndStatus(@Param("projectId") Long projectId, + @Param("ids") List ids, @Param("status") TmsTestCaseStatus status); + Boolean existsByTestFolder_Project_IdAndId(@Param("projectId") Long projectId, @Param("id") Long id); diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/util/RecordMappers.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/util/RecordMappers.java index 62e23416bd..9ce5485e56 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/util/RecordMappers.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/util/RecordMappers.java @@ -111,13 +111,16 @@ import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsAttribute; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsMilestone; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsMilestone; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsMilestoneStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsMilestoneType; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseExecution; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseExecutionComment; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestFolder; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestFolderTestItem; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestPlan; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsMilestoneStatus; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsMilestoneType; import com.epam.reportportal.base.infrastructure.persistence.entity.user.OrganizationUser; import com.epam.reportportal.base.infrastructure.persistence.entity.user.OrganizationUserId; import com.epam.reportportal.base.infrastructure.persistence.entity.user.ProjectUser; @@ -837,6 +840,8 @@ public class RecordMappers { testCase.setDescription(r.get(TMS_TEST_CASE.DESCRIPTION)); testCase.setPriority(r.get(TMS_TEST_CASE.PRIORITY)); testCase.setDisplayId(r.get(TMS_TEST_CASE.DISPLAY_ID)); + ofNullable(r.get(TMS_TEST_CASE.STATUS)).map(TmsTestCaseStatus::valueOf).ifPresent(testCase::setStatus); + ofNullable(r.get(TMS_TEST_CASE.ORIGIN)).map(TmsTestCaseOrigin::valueOf).ifPresent(testCase::setOrigin); ofNullable(r.get(TMS_TEST_CASE.TEST_FOLDER_ID)).ifPresent(folderId -> { TmsTestFolder testFolder = new TmsTestFolder(); diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/activity/EventObject.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/activity/EventObject.java index 57242e50f6..8eadc3f571 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/activity/EventObject.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/activity/EventObject.java @@ -38,7 +38,10 @@ public enum EventObject { INVITATION_LINK("Invitation link"), TMS_TEST_CASE("tmsTestCase"), LOG_TYPE("logType"), - NOTIFICATION_RULE("Notification_rule"); + NOTIFICATION_RULE("Notification_rule"), + PIPELINE("pipeline"), + PIPELINE_ITERATION("pipelineIteration"), + PIPELINE_STAGE("pipelineStage"); private final String value; diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/Pipeline.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/Pipeline.java new file mode 100644 index 0000000000..b1dfb699bc --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/Pipeline.java @@ -0,0 +1,71 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Type; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * A named AI-agent pipeline definition (e.g. {@code factory-tc-gen}). Groups the + * iterations reported for it and carries its settings (e.g. Auto-Ready). {@code type} is a + * free-form kind (e.g. {@code TEST_CASE_GENERATION}) the UI and the stage contributors key off; + * {@code attributes} describe the pipeline itself (repository, ...). + */ +@Entity +@Table(name = "pipeline", schema = "public") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Pipeline implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "type") + private String type; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Type(PipelineAttributes.class) + @Column(name = "attributes", columnDefinition = "jsonb") + private PipelineAttributes attributes; + + @Type(PipelineSettings.class) + @Column(name = "settings", columnDefinition = "jsonb") + private PipelineSettings settings; + + @CreatedDate + @Column(name = "created_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant createdAt; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineAttributes.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineAttributes.java new file mode 100644 index 0000000000..b908937135 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineAttributes.java @@ -0,0 +1,36 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Typed attributes of a pipeline, iteration, stage or stage attempt, stored as a plain JSON array. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineAttributes extends JsonbUserType implements Serializable { + + private List items = new ArrayList<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineAttributes(List items) { + this.items = items == null ? new ArrayList<>() : new ArrayList<>(items); + } + + @JsonValue + public List getItems() { + return items; + } + + @Override + public Class returnedClass() { + return PipelineAttributes.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineExecutionData.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineExecutionData.java new file mode 100644 index 0000000000..ae07b172d4 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineExecutionData.java @@ -0,0 +1,34 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Where and how a stage or stage attempt ran, stored as a plain JSON object. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineExecutionData extends JsonbUserType implements Serializable { + + private PipelineExecution content; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineExecutionData(PipelineExecution content) { + this.content = content; + } + + @JsonValue + public PipelineExecution getContent() { + return content; + } + + @Override + public Class returnedClass() { + return PipelineExecutionData.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineIteration.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineIteration.java new file mode 100644 index 0000000000..a2718fbaab --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineIteration.java @@ -0,0 +1,103 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Type; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * One run of a {@link Pipeline}: a numbered iteration made up of stages, ingested + * from CI. No direct link to {@code Launch} — the real Pipeline-to-Launch + * relationship, if any test case it generates is later executed, already exists + * through {@code TmsTestCase -> TmsTestCaseExecution -> TestItem -> Launch}. + *

+ * Everything descriptive (requirement, trigger, model, cost, tags, links) lives in the typed + * {@code attributes}; {@code searchText} is derived from them on write for list search. + * {@code status} is the derived label of the iteration, {@code statusCategory} what it counts as. + */ +@Entity +@Table(name = "pipeline_iteration", schema = "public") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PipelineIteration implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pipeline_id", nullable = false) + private Pipeline pipeline; + + @Column(name = "iteration_number", nullable = false) + private Integer iterationNumber; + + @Column(name = "status", nullable = false) + private String status; + + @Enumerated(EnumType.STRING) + @Column(name = "status_category", nullable = false) + private StatusCategory statusCategory; + + @Type(PipelineAttributes.class) + @Column(name = "attributes", columnDefinition = "jsonb") + private PipelineAttributes attributes; + + @Column(name = "search_text", columnDefinition = "TEXT") + private String searchText; + + @Column(name = "attribute_index", columnDefinition = "TEXT") + private String attributeIndex; + + @Column(name = "started_at") + @Convert(converter = JpaInstantConverter.class) + private Instant startedAt; + + @Column(name = "finished_at") + @Convert(converter = JpaInstantConverter.class) + private Instant finishedAt; + + @Column(name = "rerun", nullable = false) + private boolean rerun; + + @Column(name = "idempotency_key") + private String idempotencyKey; + + @Column(name = "idempotency_hash") + private String idempotencyHash; + + @Column(name = "rerun_of_iteration_id") + private Long rerunOfIterationId; + + @Column(name = "created_by") + private Long createdBy; + + @CreatedDate + @Column(name = "created_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant createdAt; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineLinks.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineLinks.java new file mode 100644 index 0000000000..72c87d691b --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineLinks.java @@ -0,0 +1,36 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.model.PipelineLink; +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Links of a stage (to a CI job, to something in ReportPortal, ...), stored as a plain JSON array. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineLinks extends JsonbUserType implements Serializable { + + private List items = new ArrayList<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineLinks(List items) { + this.items = items == null ? new ArrayList<>() : new ArrayList<>(items); + } + + @JsonValue + public List getItems() { + return items; + } + + @Override + public Class returnedClass() { + return PipelineLinks.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRawOutput.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRawOutput.java new file mode 100644 index 0000000000..0403a17255 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRawOutput.java @@ -0,0 +1,36 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * The raw output a stage reported, kept exactly as received (e.g. the grader's output) next to the + * reference of the schema it was validated against. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineRawOutput extends JsonbUserType implements Serializable { + + private Map content = new LinkedHashMap<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineRawOutput(Map content) { + this.content = content == null ? new LinkedHashMap<>() : new LinkedHashMap<>(content); + } + + @JsonValue + public Map getContent() { + return content; + } + + @Override + public Class returnedClass() { + return PipelineRawOutput.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRelatedEntities.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRelatedEntities.java new file mode 100644 index 0000000000..5a55589e9d --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRelatedEntities.java @@ -0,0 +1,39 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * The things elsewhere in ReportPortal a stage reported it worked on or produced, kept as reported and + * stored as a plain JSON array. + *

+ * Example: {@code [{"entityType": "TEST_CASE", "entityId": "TC-12"}]}. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineRelatedEntities extends JsonbUserType implements Serializable { + + private List items = new ArrayList<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineRelatedEntities(List items) { + this.items = items == null ? new ArrayList<>() : new ArrayList<>(items); + } + + @JsonValue + public List getItems() { + return items; + } + + @Override + public Class returnedClass() { + return PipelineRelatedEntities.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSections.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSections.java new file mode 100644 index 0000000000..c00ef78f14 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSections.java @@ -0,0 +1,36 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Result tables of a stage or stage attempt, stored as a plain JSON array. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineSections extends JsonbUserType implements Serializable { + + private List items = new ArrayList<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineSections(List items) { + this.items = items == null ? new ArrayList<>() : new ArrayList<>(items); + } + + @JsonValue + public List getItems() { + return items; + } + + @Override + public Class returnedClass() { + return PipelineSections.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSettings.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSettings.java new file mode 100644 index 0000000000..271dad683e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSettings.java @@ -0,0 +1,39 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.infrastructure.persistence.commons.JsonbUserType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import java.io.Serializable; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * Settings of a pipeline, stored as one JSON object. Behaviour plugged into the pipeline reads its own + * entries and the core never interprets them. + *

+ * Example: {@code {"autoReady": {"enabled": true, "threshold": 90}}} switches on Auto-Ready for the test + * cases a test case generation pipeline produces. + */ +@NoArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class PipelineSettings extends JsonbUserType implements Serializable { + + private Map values = new LinkedHashMap<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineSettings(Map values) { + this.values = values == null ? new LinkedHashMap<>() : new LinkedHashMap<>(values); + } + + @JsonValue + public Map getValues() { + return values; + } + + @Override + public Class returnedClass() { + return PipelineSettings.class; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStage.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStage.java new file mode 100644 index 0000000000..93930fe254 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStage.java @@ -0,0 +1,147 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import com.epam.reportportal.base.infrastructure.persistence.entity.LTreeType; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Type; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * A single step ("any activity") within a {@link PipelineIteration}, close to an + * OpenTelemetry span / Temporal activity: free-form {@code stageKey}, and may + * nest other stages via {@code parentStageId}/{@code path} (same convention as + * {@code TestItem.path} — maintained in application code, no DB trigger), so + * any business process can be modelled, not just test-case generation. Carries + * its own CI reference so a stage can be individually retried by re-triggering + * the exact job that ran it, and a generic {@code resultType}/{@code resultRef} + * pointer for whatever artifact it produced (e.g. a Launch). + *

+ * {@code status} is the text exactly as reported; {@code statusCategory} is what it counts as. + * What the stage did is described by typed {@code attributes} (the chip metric, cost, ...) and + * {@code sections} (result tables); {@code rawOutput} keeps what the stage reported as received. + */ +@Entity +@Table(name = "pipeline_stage", schema = "public") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStage implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "iteration_id", nullable = false) + private PipelineIteration iteration; + + @Column(name = "stage_key", nullable = false) + private String stageKey; + + @Column(name = "name") + private String name; + + @Column(name = "sequence", nullable = false) + private Integer sequence; + + @Column(name = "attempt", nullable = false) + private int attempt = 1; + + @Column(name = "parent_stage_id") + private Long parentStageId; + + @Column(name = "path", columnDefinition = "ltree") + @Type(LTreeType.class) + private String path; + + @Column(name = "status", nullable = false) + private String status; + + @Enumerated(EnumType.STRING) + @Column(name = "status_category", nullable = false) + private StatusCategory statusCategory; + + @Enumerated(EnumType.STRING) + @Column(name = "owned_by", nullable = false) + private StageOwner ownedBy = StageOwner.PIPELINE; + + @Column(name = "affects_iteration_status", nullable = false) + private boolean affectsIterationStatus = true; + + @Type(PipelineAttributes.class) + @Column(name = "attributes", columnDefinition = "jsonb") + private PipelineAttributes attributes; + + @Type(PipelineSections.class) + @Column(name = "sections", columnDefinition = "jsonb") + private PipelineSections sections; + + @Type(PipelineRelatedEntities.class) + @Column(name = "related_entities", columnDefinition = "jsonb") + private PipelineRelatedEntities relatedEntities; + + @Column(name = "summary") + private String summary; + + @Column(name = "message") + private String message; + + @Type(PipelineLinks.class) + @Column(name = "links", columnDefinition = "jsonb") + private PipelineLinks links; + + @Type(PipelineRawOutput.class) + @Column(name = "raw_output", columnDefinition = "jsonb") + private PipelineRawOutput rawOutput; + + @Column(name = "raw_output_schema_name") + private String rawOutputSchemaName; + + @Column(name = "result_type") + private String resultType; + + @Column(name = "result_ref") + private String resultRef; + + @Column(name = "started_at") + @Convert(converter = JpaInstantConverter.class) + private Instant startedAt; + + @Column(name = "finished_at") + @Convert(converter = JpaInstantConverter.class) + private Instant finishedAt; + + @Type(PipelineExecutionData.class) + @Column(name = "execution", columnDefinition = "jsonb") + private PipelineExecutionData execution; + + @Column(name = "last_retried_at") + @Convert(converter = JpaInstantConverter.class) + private Instant lastRetriedAt; + + @Column(name = "last_retried_by") + private Long lastRetriedBy; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStageAttempt.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStageAttempt.java new file mode 100644 index 0000000000..ab276526fe --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStageAttempt.java @@ -0,0 +1,101 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Type; + +/** + * A finished attempt of a stage, kept when the stage is run again (a retry, or a stage reported anew). + * Keyed by {@code (iterationId, stageKey)} rather than the stage id so it survives the stage row being + * recreated. + */ +@Entity +@Table(name = "pipeline_stage_attempt", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStageAttempt implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "iteration_id", nullable = false) + private Long iterationId; + + @Column(name = "stage_key", nullable = false) + private String stageKey; + + @Column(name = "attempt", nullable = false) + private int attempt; + + @Column(name = "status", nullable = false) + private String status; + + @Enumerated(EnumType.STRING) + @Column(name = "status_category", nullable = false) + private StatusCategory statusCategory; + + @Column(name = "started_at") + @Convert(converter = JpaInstantConverter.class) + private Instant startedAt; + + @Column(name = "finished_at") + @Convert(converter = JpaInstantConverter.class) + private Instant finishedAt; + + @Type(PipelineExecutionData.class) + @Column(name = "execution", columnDefinition = "jsonb") + private PipelineExecutionData execution; + + @Column(name = "result_type") + private String resultType; + + @Column(name = "result_ref") + private String resultRef; + + @Type(PipelineAttributes.class) + @Column(name = "attributes", columnDefinition = "jsonb") + private PipelineAttributes attributes; + + @Type(PipelineSections.class) + @Column(name = "sections", columnDefinition = "jsonb") + private PipelineSections sections; + + /** + * Snapshot of the stage's current attempt, taken just before the stage starts a new one. + */ + public static PipelineStageAttempt snapshotOf(PipelineStage stage) { + var attempt = new PipelineStageAttempt(); + attempt.setIterationId(stage.getIteration().getId()); + attempt.setStageKey(stage.getStageKey()); + attempt.setAttempt(stage.getAttempt()); + attempt.setStatus(stage.getStatus()); + attempt.setStatusCategory(stage.getStatusCategory()); + attempt.setStartedAt(stage.getStartedAt()); + attempt.setFinishedAt(stage.getFinishedAt()); + attempt.setExecution(stage.getExecution()); + attempt.setResultType(stage.getResultType()); + attempt.setResultRef(stage.getResultRef()); + attempt.setAttributes(stage.getAttributes() == null ? null : new PipelineAttributes(stage.getAttributes().getItems())); + attempt.setSections(stage.getSections() == null ? null : new PipelineSections(stage.getSections().getItems())); + return attempt; + } +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/enums/IntegrationGroupEnum.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/enums/IntegrationGroupEnum.java index b1261aba3f..3b96b57900 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/enums/IntegrationGroupEnum.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/enums/IntegrationGroupEnum.java @@ -25,7 +25,8 @@ public enum IntegrationGroupEnum { NOTIFICATION, AUTH, OTHER, - IMPORT; + IMPORT, + AUTOMATION; public static Optional findByName(String name) { return Arrays.stream(IntegrationGroupEnum.values()).filter(i -> i.name().equalsIgnoreCase(name)) diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/PipelineStageTestCase.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/PipelineStageTestCase.java new file mode 100644 index 0000000000..bcc09a04f6 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/PipelineStageTestCase.java @@ -0,0 +1,44 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Links a {@link PipelineStage} to a {@link TmsTestCase} it generated or touched. + * Unidirectional on purpose: {@code TmsTestCase} carries no knowledge of Pipeline + * at all, so the TMS entity is untouched by this feature. + */ +@Entity +@Table(name = "pipeline_stage_test_case", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PipelineStageTestCase implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stage_id", nullable = false) + private PipelineStage stage; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "test_case_id", nullable = false) + private TmsTestCase testCase; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandard.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandard.java new file mode 100644 index 0000000000..d3d25999ad --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandard.java @@ -0,0 +1,69 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * A project's AI test-case quality rubric: a set of criteria whose max points + * sum to 100. Exactly one per project for MVP. + */ +@Entity +@Table(name = "tms_quality_standard", schema = "public") +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TmsQualityStandard implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "project_id", nullable = false, unique = true) + private Long projectId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @OneToMany(mappedBy = "standard", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sequence ASC") + @ToString.Exclude + private List criteria; + + @CreatedDate + @Column(name = "created_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant updatedAt; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandardCriterion.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandardCriterion.java new file mode 100644 index 0000000000..f641568a5f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandardCriterion.java @@ -0,0 +1,50 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A single scored criterion of a {@link TmsQualityStandard} (e.g. "Scenario + * correctness", worth 25 of the standard's 100 points). + */ +@Entity +@Table(name = "tms_quality_standard_criterion", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TmsQualityStandardCriterion implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "standard_id", nullable = false) + private TmsQualityStandard standard; + + @Column(name = "criterion_key") + private String key; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "max_points", nullable = false) + private Integer maxPoints; + + @Column(name = "sequence", nullable = false) + private Integer sequence; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCase.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCase.java index 998790d369..58c7922f38 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCase.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCase.java @@ -4,10 +4,14 @@ import com.epam.reportportal.base.infrastructure.persistence.entity.item.TestItem; import com.epam.reportportal.base.infrastructure.persistence.entity.launch.Launch; import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -64,6 +68,14 @@ public class TmsTestCase implements Serializable { @Column(name = "display_id") private String displayId; + @Enumerated(EnumType.STRING) + @Column(name = "origin", nullable = false) + private TmsTestCaseOrigin origin = TmsTestCaseOrigin.MANUAL; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private TmsTestCaseStatus status = TmsTestCaseStatus.READY; + @ManyToOne @JoinColumn(name = "project_id", nullable = false) private Project project; diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseGenerationMetadata.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseGenerationMetadata.java new file mode 100644 index 0000000000..e1b40ab3fb --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseGenerationMetadata.java @@ -0,0 +1,55 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.math.BigDecimal; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * How a {@link TmsTestCaseVersion} was generated: tokens, model, skill and + * cost. 1:1 with the version it describes, following the same "belongs to the + * version" reasoning as {@link TmsTestCaseQualityScore}. + */ +@Entity +@Table(name = "tms_test_case_generation_metadata", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseGenerationMetadata implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "test_case_version_id", nullable = false, unique = true) + private TmsTestCaseVersion testCaseVersion; + + @Column(name = "tokens_in") + private Integer tokensIn; + + @Column(name = "tokens_out") + private Integer tokensOut; + + @Column(name = "model") + private String model; + + @Column(name = "skill") + private String skill; + + @Column(name = "cost_usd") + private BigDecimal costUsd; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseQualityScore.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseQualityScore.java new file mode 100644 index 0000000000..e015bebc19 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseQualityScore.java @@ -0,0 +1,61 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * The AI-agent-reported score of one {@link TmsQualityStandardCriterion} for a + * specific {@link TmsTestCaseVersion}. The version this belongs to earned the + * score for its content, so a new version naturally makes the score obsolete. + */ +@Entity +@Table(name = "tms_test_case_quality_score", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseQualityScore implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "test_case_version_id", nullable = false) + private TmsTestCaseVersion testCaseVersion; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "criterion_id", nullable = false) + private TmsQualityStandardCriterion criterion; + + @Column(name = "score", nullable = false) + private Integer score; + + @Column(name = "evaluated_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant evaluatedAt; + + /** + * Optional link to the Pipeline iteration that produced this score. Plain id, + * not a JPA relation: the Pipeline domain is independent of TMS, and this is + * the only place TMS points back at it. + */ + @Column(name = "pipeline_iteration_id") + private Long pipelineIterationId; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseVersion.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseVersion.java index 9956376451..067c2e2637 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseVersion.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseVersion.java @@ -1,6 +1,8 @@ package com.epam.reportportal.base.infrastructure.persistence.entity.tms; +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; import jakarta.persistence.Column; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -11,6 +13,7 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.io.Serializable; +import java.time.Instant; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -45,6 +48,10 @@ public class TmsTestCaseVersion implements Serializable { @OneToOne(mappedBy = "testCaseVersion") private TmsManualScenario manualScenario; + @Column(name = "updated_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant updatedAt; + //TODO: override equals and hashCode methods } diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseOrigin.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseOrigin.java new file mode 100644 index 0000000000..7eacd7bb10 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseOrigin.java @@ -0,0 +1,6 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums; + +public enum TmsTestCaseOrigin { + MANUAL, + AI +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseStatus.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseStatus.java new file mode 100644 index 0000000000..e7ad0ea18e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseStatus.java @@ -0,0 +1,6 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums; + +public enum TmsTestCaseStatus { + DRAFT, + READY +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/JTmsTestCase.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/JTmsTestCase.java index 0ff996ff2b..f6fa6553d1 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/JTmsTestCase.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/JTmsTestCase.java @@ -129,6 +129,16 @@ public Class getRecordType() { */ public final TableField DATASET_ID = createField(DSL.name("dataset_id"), SQLDataType.BIGINT, this, ""); + /** + * The column public.tms_test_case.origin. + */ + public final TableField ORIGIN = createField(DSL.name("origin"), SQLDataType.VARCHAR(16).nullable(false).defaultValue(DSL.field(DSL.raw("'MANUAL'::character varying"), SQLDataType.VARCHAR)), this, ""); + + /** + * The column public.tms_test_case.status. + */ + public final TableField STATUS = createField(DSL.name("status"), SQLDataType.VARCHAR(16).nullable(false).defaultValue(DSL.field(DSL.raw("'READY'::character varying"), SQLDataType.VARCHAR)), this, ""); + private JTmsTestCase(Name alias, Table aliased) { this(alias, aliased, (Field[]) null, null); } diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/records/JTmsTestCaseRecord.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/records/JTmsTestCaseRecord.java index 1acadf16bf..174928e7e2 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/records/JTmsTestCaseRecord.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/jooq/tables/records/JTmsTestCaseRecord.java @@ -188,6 +188,34 @@ public Long getDatasetId() { return (Long) get(11); } + /** + * Setter for public.tms_test_case.origin. + */ + public void setOrigin(String value) { + set(12, value); + } + + /** + * Getter for public.tms_test_case.origin. + */ + public String getOrigin() { + return (String) get(12); + } + + /** + * Setter for public.tms_test_case.status. + */ + public void setStatus(String value) { + set(13, value); + } + + /** + * Getter for public.tms_test_case.status. + */ + public String getStatus() { + return (String) get(13); + } + // ------------------------------------------------------------------------- // Primary key information // ------------------------------------------------------------------------- @@ -211,7 +239,7 @@ public JTmsTestCaseRecord() { /** * Create a detached, initialised JTmsTestCaseRecord */ - public JTmsTestCaseRecord(Long id, Instant createdAt, Instant updatedAt, String name, String description, String priority, Object searchVector, String externalId, String displayId, Long projectId, Long testFolderId, Long datasetId) { + public JTmsTestCaseRecord(Long id, Instant createdAt, Instant updatedAt, String name, String description, String priority, Object searchVector, String externalId, String displayId, Long projectId, Long testFolderId, Long datasetId, String origin, String status) { super(JTmsTestCase.TMS_TEST_CASE); setId(id); @@ -226,6 +254,8 @@ public JTmsTestCaseRecord(Long id, Instant createdAt, Instant updatedAt, String setProjectId(projectId); setTestFolderId(testFolderId); setDatasetId(datasetId); + setOrigin(origin); + setStatus(status); resetChangedOnNotNull(); } } diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/rules/exception/ErrorType.java b/src/main/java/com/epam/reportportal/base/infrastructure/rules/exception/ErrorType.java index 4e1ce202d3..5fc92831c0 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/rules/exception/ErrorType.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/rules/exception/ErrorType.java @@ -351,6 +351,16 @@ public enum ErrorType { */ USER_ALREADY_ASSIGNED(40912, "User '{}' cannot be assigned to {} twice. You couldn't create the duplicate."), + /** + * Pipeline stage is in a state that does not allow the requested retry (e.g. a retry is already in progress) + */ + PIPELINE_STAGE_RETRY_CONFLICT(40913, "Pipeline stage retry conflict. {}"), + + /** + * An Idempotency-Key was reused with a different request body + */ + IDEMPOTENCY_KEY_CONFLICT(40914, "Idempotency key conflict. {}"), + /** * Unable remove resource from favorites */ diff --git a/src/main/resources/schema/manifest.schema.json b/src/main/resources/schema/manifest.schema.json index 57dca0493d..ceba59401e 100644 --- a/src/main/resources/schema/manifest.schema.json +++ b/src/main/resources/schema/manifest.schema.json @@ -103,7 +103,6 @@ "items": { "type": "object", "description": "The project pages that the app provides.", - "minItems": 1, "properties": { "slug": { "$ref": "#/$defs/slug", @@ -141,49 +140,6 @@ ] } }, - "adminPages": { - "type": "array", - "items": { - "type": "object", - "description": "The instance administration pages that the app provides.", - "minItems": 1, - "properties": { - "slug": { - "$ref": "#/$defs/slug", - "description": "A unique identifier for this admin page.", - "maxLength": 32 - }, - "name": { - "type": "string", - "description": "The display name for the admin page.", - "maxLength": 32 - }, - "title": { - "type": "string", - "description": "Optional sidebar label. Falls back to name when omitted.", - "maxLength": 32 - }, - "menuOrder": { - "type": "integer", - "description": "Optional sidebar position. Lower values appear higher. When omitted, the page is placed after the built-in sidebar items.", - "minimum": 0 - }, - "url": { - "$ref": "#/$defs/relativePath", - "description": "A relative file path to the admin page." - }, - "icon": { - "$ref": "#/$defs/icon" - } - }, - "required": [ - "slug", - "name", - "url", - "icon" - ] - } - }, "adminPages": { "type": "array", "minItems": 1, @@ -363,4 +319,4 @@ ] } } -} +} \ No newline at end of file diff --git a/src/main/resources/schema/pipeline/grade.schema.json b/src/main/resources/schema/pipeline/grade.schema.json new file mode 100644 index 0000000000..263c393832 --- /dev/null +++ b/src/main/resources/schema/pipeline/grade.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$comment": "Derived from US-AI-FCTRY-001 (grade document: per case name, criteria with score / max_score / failure_reasons, case total_score; suite total_score, warnings, optional error). Replace with the official grade.schema.json from rp-tests when it is available.", + "type": "object", + "required": ["total_score"], + "properties": { + "total_score": {"type": "number", "minimum": 0}, + "warnings": {"type": "array", "items": {"type": "string"}}, + "error": {"type": ["string", "null"]}, + "cases": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "total_score"], + "properties": { + "name": {"type": "string", "minLength": 1}, + "total_score": {"type": "number", "minimum": 0}, + "criteria": { + "type": ["array", "object"] + } + } + } + } + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/PipelineCoreLayeringTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/PipelineCoreLayeringTest.java new file mode 100644 index 0000000000..0e580be9e8 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/PipelineCoreLayeringTest.java @@ -0,0 +1,35 @@ +package com.epam.reportportal.base.core.aifactory; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import org.junit.jupiter.api.Test; + +/** + * The pipeline core describes any process (an AI agent run, a CI/CD build, a manual routine) and must not + * know about test cases. Whatever is specific to test cases, for example linking a stage to the test cases + * it produced, lives on the test case side (package {@code core.tms.pipeline}) and plugs in through the + * extension points of the core, such as {@code PipelineRelatedEntityResolver} and {@code PipelineStageContributor}. + */ +class PipelineCoreLayeringTest { + + @Test + void pipelineCore_ShouldNotDependOnTestCaseCode() { + var productionClasses = new ClassFileImporter() + .withImportOption(new ImportOption.DoNotIncludeTests()) + .importPackages("com.epam.reportportal.base"); + + noClasses() + .that().resideInAnyPackage( + "..core.aifactory..", + "..infrastructure.persistence.entity.aifactory..", + "..infrastructure.persistence.dao.aifactory..") + .should().dependOnClassesThat().resideInAnyPackage( + "..core.tms..", + "..infrastructure.persistence.entity.tms..", + "..infrastructure.persistence.dao.tms..") + .because("the pipeline core is generic; test case behaviour is an implementation plugged into it") + .check(productionClasses); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/PipelineTestData.java b/src/test/java/com/epam/reportportal/base/core/aifactory/PipelineTestData.java new file mode 100644 index 0000000000..ec3f612d3d --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/PipelineTestData.java @@ -0,0 +1,29 @@ +package com.epam.reportportal.base.core.aifactory; + +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import java.util.LinkedHashMap; +import java.util.List; + +/** + * Builders for report fragments shared by the pipeline tests. + */ +public final class PipelineTestData { + + private PipelineTestData() { + } + + /** + * A result table with one row per entity whose "item" cell links to that entity, the way an upload report + * links the test cases it created. + */ + public static List sectionLinking(List entities) { + var rows = entities.stream().map(entity -> { + var cells = new LinkedHashMap(); + cells.put("item", PipelineSection.Cell.builder().value(entity.getEntityId()).target(entity).build()); + return PipelineSection.Row.builder().key(entity.getEntityId()).cells(cells).build(); + }).toList(); + return List.of(PipelineSection.builder().key("report") + .columns(List.of(PipelineSection.Column.builder().key("item").label("Item").build())) + .rows(rows).build()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnectorTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnectorTest.java new file mode 100644 index 0000000000..f5fd820c60 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnectorTest.java @@ -0,0 +1,120 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineExecutionData; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.IntegrationParams; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class GitHubActionsCiConnectorTest { + + private final CiHttpClient ciHttpClient = mock(CiHttpClient.class); + private final GitHubActionsCiConnector sut = new GitHubActionsCiConnector(ciHttpClient, new ObjectMapper()); + + private PipelineStage stage; + + @BeforeEach + void setUp() { + stage = new PipelineStage(); + stage.setId(50L); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder() + .executorType("GITHUB_ACTIONS").externalRunId("78911") + .retryParameters(new HashMap<>(Map.of("repo", "org/repo", "workflowRef", "ai-pipeline.yml"))).build())); + } + + private Integration integrationWithParams(Map params) { + var integration = new Integration(); + integration.setParams(new IntegrationParams(params)); + return integration; + } + + @Test + void getSupportedExecutorType_ShouldBeGitHubActionsAndUseItsIntegrationType() { + assertEquals("GITHUB_ACTIONS", sut.getSupportedExecutorType()); + assertEquals("github-actions", sut.getIntegrationTypeName()); + } + + @Test + void triggerStageRerun_ShouldDispatchWorkflowWithTokenAndReturnActionsUrl() { + var integration = integrationWithParams(Map.of("token", "gh-token", "ref", "release-branch", "repo", "org/repo")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + + var result = sut.triggerStageRerun(integration, stage); + + assertEquals("https://github.com/org/repo/actions/workflows/ai-pipeline.yml", result); + + var urlCaptor = ArgumentCaptor.forClass(String.class); + var headersCaptor = ArgumentCaptor.forClass(Map.class); + var bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(ciHttpClient).postForStatus(urlCaptor.capture(), headersCaptor.capture(), bodyCaptor.capture()); + + assertEquals("https://api.github.com/repos/org/repo/actions/workflows/ai-pipeline.yml/dispatches", + urlCaptor.getValue()); + assertEquals("Bearer gh-token", headersCaptor.getValue().get("Authorization")); + assertTrue(bodyCaptor.getValue().contains("\"ref\":\"release-branch\"")); + assertTrue(bodyCaptor.getValue().contains("\"pipeline_stage_id\":\"50\"")); + assertTrue(bodyCaptor.getValue().contains("\"rerun_job_id\":\"78911\"")); + } + + @Test + void triggerStageRerun_WithoutGitRefParam_ShouldDefaultToMain() { + var integration = integrationWithParams(Map.of("token", "gh-token", "repo", "org/repo")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + + sut.triggerStageRerun(integration, stage); + + var bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(ciHttpClient).postForStatus(anyString(), anyMap(), bodyCaptor.capture()); + assertTrue(bodyCaptor.getValue().contains("\"ref\":\"main\"")); + } + + @Test + void triggerStageRerun_WhenMissingCiRepoOrWorkflowRef_ShouldThrowBadRequestWithoutCallingHttpClient() { + stage.getExecution().getContent().getRetryParameters().remove("workflowRef"); + var integration = integrationWithParams(Map.of("token", "gh-token", "repo", "org/repo")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void triggerStageRerun_WhenIntegrationMissingToken_ShouldThrowBadRequestWithoutCallingHttpClient() { + var integration = integrationWithParams(Map.of("repo", "org/repo")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void triggerStageRerun_WhenIntegrationMissingRepo_ShouldThrowBadRequestWithoutCallingHttpClient() { + var integration = integrationWithParams(Map.of("token", "gh-token")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void triggerStageRerun_WhenStageRepoDoesNotMatchIntegrationRepo_ShouldThrowBadRequestWithoutCallingHttpClient() { + var integration = integrationWithParams(Map.of("token", "gh-token", "repo", "org/other-repo")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnectorTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnectorTest.java new file mode 100644 index 0000000000..027d17d424 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnectorTest.java @@ -0,0 +1,141 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineExecutionData; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.IntegrationParams; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class GitLabCiConnectorTest { + + private final CiHttpClient ciHttpClient = mock(CiHttpClient.class); + private final GitLabCiConnector sut = new GitLabCiConnector(ciHttpClient, new ObjectMapper()); + + private PipelineStage stage; + + @BeforeEach + void setUp() { + stage = new PipelineStage(); + stage.setId(50L); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder() + .executorType("GITLAB_CI").externalRunId("78911") + .retryParameters(new HashMap<>(Map.of("repo", "42", "workflowRef", "main"))).build())); + } + + private Integration integrationWithParams(Map params) { + var integration = new Integration(); + integration.setParams(new IntegrationParams(params)); + return integration; + } + + @Test + void getSupportedExecutorType_ShouldBeGitLabCiAndUseItsIntegrationType() { + assertEquals("GITLAB_CI", sut.getSupportedExecutorType()); + assertEquals("gitlab-ci", sut.getIntegrationTypeName()); + } + + @Test + void triggerStageRerun_ShouldTriggerPipelineAndReturnWebUrlFromResponse() { + var integration = integrationWithParams( + Map.of("triggerToken", "gl-token", "url", "https://gitlab.internal", "projectId", "42")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())) + .thenReturn("{\"web_url\": \"https://gitlab.internal/org/repo/-/pipelines/999\"}"); + + var result = sut.triggerStageRerun(integration, stage); + + assertEquals("https://gitlab.internal/org/repo/-/pipelines/999", result); + + var urlCaptor = ArgumentCaptor.forClass(String.class); + var bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(ciHttpClient).postForStatus(urlCaptor.capture(), anyMap(), bodyCaptor.capture()); + + assertEquals("https://gitlab.internal/api/v4/projects/42/trigger/pipeline", urlCaptor.getValue()); + assertTrue(bodyCaptor.getValue().contains("\"token\":\"gl-token\"")); + assertTrue(bodyCaptor.getValue().contains("\"ref\":\"main\"")); + assertTrue(bodyCaptor.getValue().contains("\"PIPELINE_STAGE_ID\":\"50\"")); + assertTrue(bodyCaptor.getValue().contains("\"RERUN_JOB_ID\":\"78911\"")); + } + + @Test + void triggerStageRerun_WhenResponseHasNoWebUrl_ShouldReturnFallbackPipelinesUrl() { + var integration = integrationWithParams( + Map.of("triggerToken", "gl-token", "url", "https://gitlab.internal", "projectId", "42")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn("{}"); + + var result = sut.triggerStageRerun(integration, stage); + + assertEquals("https://gitlab.internal/api/v4/projects/42/pipelines", result); + } + + @Test + void triggerStageRerun_WhenResponseIsNotJson_ShouldReturnFallbackPipelinesUrl() { + var integration = integrationWithParams( + Map.of("triggerToken", "gl-token", "url", "https://gitlab.internal", "projectId", "42")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn("not-json"); + + var result = sut.triggerStageRerun(integration, stage); + + assertEquals("https://gitlab.internal/api/v4/projects/42/pipelines", result); + } + + @Test + void triggerStageRerun_WithoutBaseUrlParam_ShouldDefaultToGitlabCom() { + var integration = integrationWithParams(Map.of("triggerToken", "gl-token", "projectId", "42")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn("{}"); + + var result = sut.triggerStageRerun(integration, stage); + + assertEquals("https://gitlab.com/api/v4/projects/42/pipelines", result); + } + + @Test + void triggerStageRerun_WhenMissingCiRepoOrWorkflowRef_ShouldThrowBadRequestWithoutCallingHttpClient() { + stage.getExecution().getContent().getRetryParameters().remove("repo"); + var integration = integrationWithParams(Map.of("triggerToken", "gl-token", "projectId", "42")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void triggerStageRerun_WhenIntegrationMissingToken_ShouldThrowBadRequestWithoutCallingHttpClient() { + var integration = integrationWithParams(Map.of("projectId", "42")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void triggerStageRerun_WhenIntegrationMissingProjectId_ShouldThrowBadRequestWithoutCallingHttpClient() { + var integration = integrationWithParams(Map.of("triggerToken", "gl-token")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void triggerStageRerun_WhenStageProjectDoesNotMatchIntegrationProjectId_ShouldThrowBadRequestWithoutCallingHttpClient() { + var integration = integrationWithParams(Map.of("triggerToken", "gl-token", "projectId", "99")); + + assertThrows(ReportPortalException.class, () -> sut.triggerStageRerun(integration, stage)); + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/controller/PipelineControllerTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/PipelineControllerTest.java new file mode 100644 index 0000000000..7e69ad70d3 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/PipelineControllerTest.java @@ -0,0 +1,334 @@ +package com.epam.reportportal.base.core.aifactory.controller; + +import static com.epam.reportportal.base.util.StandaloneMockMvcSupport.standaloneJsonSetup; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epam.reportportal.base.core.aifactory.dto.IterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationPatchRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineSettingsRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRS; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationFilter; +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationIngestResult; +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationService; +import com.epam.reportportal.base.core.aifactory.service.PipelineService; +import com.epam.reportportal.base.core.aifactory.service.PipelineStageRetryService; +import com.epam.reportportal.base.infrastructure.persistence.commons.ReportPortalUser; +import com.epam.reportportal.base.infrastructure.persistence.entity.organization.MembershipDetails; +import com.epam.reportportal.base.util.OffsetRequest; +import com.epam.reportportal.base.util.ProjectExtractor; +import com.epam.reportportal.base.ws.resolver.OffsetArgumentResolver; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.MethodParameter; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +class PipelineControllerTest { + + private final long projectId = 1L; + private final String projectKey = "test_project"; + + @Mock + private PipelineService pipelineService; + @Mock + private PipelineIterationService pipelineIterationService; + @Mock + private PipelineStageRetryService pipelineStageRetryService; + @Mock + private ProjectExtractor projectExtractor; + + @InjectMocks + private PipelineController pipelineController; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private ReportPortalUser testUser; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + objectMapper = new ObjectMapper().findAndRegisterModules(); + + testUser = ReportPortalUser.userBuilder() + .withUserName("testUser") + .withPassword("password") + .withUserId(9L) + .withActive(true) + .withAuthorities(Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))) + .build(); + + mockMvc = standaloneJsonSetup(pipelineController) + .setCustomArgumentResolvers( + new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(AuthenticationPrincipal.class) != null; + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return testUser; + } + }, + new OffsetArgumentResolver()) + .build(); + + var membershipDetails = MembershipDetails.builder() + .withProjectId(projectId) + .withProjectKey(projectKey) + .build(); + given(projectExtractor.extractMembershipDetails(eq(testUser), anyString())).willReturn(membershipDetails); + } + + @Test + void getPipelines_ShouldDelegateToServiceAndReturnPagedContent() throws Exception { + Page page = new PageImpl<>(List.of(PipelineRS.builder().id(2L).name("factory-tc-gen").build())); + given(pipelineService.getPipelines(eq(projectId), isNull(), any(OffsetRequest.class))).willReturn(page); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline", projectKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(2)) + .andExpect(jsonPath("$.content[0].name").value("factory-tc-gen")); + + verify(projectExtractor).extractMembershipDetails(eq(testUser), anyString()); + verify(pipelineService).getPipelines(eq(projectId), isNull(), any(OffsetRequest.class)); + } + + @Test + void updatePipelineSettings_ShouldDelegateToServiceAndReturnUpdatedPipeline() throws Exception { + var settings = Map.of("autoReady", Map.of("enabled", true, "threshold", 90)); + var rq = PipelineSettingsRQ.builder().settings(settings).build(); + var rs = PipelineRS.builder().id(2L).settings(settings).build(); + given(pipelineService.patchPipelineSettings(projectId, 2L, rq)).willReturn(rs); + + mockMvc.perform(patch("/v1/project/{projectKey}/pipeline/{pipelineId}", projectKey, 2L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.settings.autoReady.enabled").value(true)) + .andExpect(jsonPath("$.settings.autoReady.threshold").value(90)); + + verify(pipelineService).patchPipelineSettings(projectId, 2L, rq); + } + + @Test + void getIterations_ShouldDelegateToServiceAndReturnPagedContent() throws Exception { + Page page = new PageImpl<>( + List.of(PipelineIterationSummaryRS.builder().id(7L).iterationNumber(3).build())); + given(pipelineIterationService.getPipelineIterations(eq(projectId), eq(2L), any(PipelineIterationFilter.class), any(OffsetRequest.class))).willReturn(page); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline/{pipelineId}/iteration", projectKey, 2L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].id").value(7)) + .andExpect(jsonPath("$.content[0].iterationNumber").value(3)); + + verify(pipelineIterationService).getPipelineIterations(eq(projectId), eq(2L), any(PipelineIterationFilter.class), any(OffsetRequest.class)); + } + + @Test + void ingestIteration_ShouldDelegateToServiceWithUserIdAndReturnCreated() throws Exception { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder() + .stageKey("stage-gen-tc").sequence(1).status("Passed").build())).build()).build(); + var rs = PipelineIterationDetailRS.builder().id(7L).pipelineName("factory-tc-gen").build(); + given(pipelineIterationService.createPipelineIteration(projectId, 9L, rq, null)) + .willReturn(new PipelineIterationIngestResult(rs, false)); + + mockMvc.perform(post("/v1/project/{projectKey}/pipeline/iteration", projectKey) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(7)) + .andExpect(jsonPath("$.pipelineName").value("factory-tc-gen")); + + verify(pipelineIterationService).createPipelineIteration(projectId, 9L, rq, null); + } + + @Test + void ingestIteration_WithIdempotencyKey_ShouldPassKeyAndReturn200WithReplayHeaderOnReplay() throws Exception { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder() + .stageKey("stage-gen-tc").sequence(1).status("Passed").build())).build()).build(); + var rs = PipelineIterationDetailRS.builder().id(7L).build(); + given(pipelineIterationService.createPipelineIteration(projectId, 9L, rq, "run-42")) + .willReturn(new PipelineIterationIngestResult(rs, true)); + + mockMvc.perform(post("/v1/project/{projectKey}/pipeline/iteration", projectKey) + .header("Idempotency-Key", "run-42") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers + .header().string("Idempotent-Replayed", "true")) + .andExpect(jsonPath("$.id").value(7)); + } + + @Test + void getIteration_ShouldDelegateToServiceAndReturnDetail() throws Exception { + var rs = PipelineIterationDetailRS.builder().id(7L).build(); + given(pipelineIterationService.getPipelineIteration(projectId, 7L)).willReturn(rs); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline/iteration/{iterationId}", projectKey, 7L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(7)); + + verify(pipelineIterationService).getPipelineIteration(projectId, 7L); + } + + @Test + void compareIterations_ShouldDelegateToServiceWithBothIterationIds() throws Exception { + var rs = PipelineCompareRS.builder() + .candidate(PipelineIterationDetailRS.builder().id(7L).build()) + .baseline(PipelineIterationDetailRS.builder().id(6L).build()) + .build(); + given(pipelineIterationService.comparePipelineIterations(projectId, 7L, 6L)).willReturn(rs); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline/iteration/{iterationId}/compare", projectKey, 7L) + .param("with", "6")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.candidate.id").value(7)) + .andExpect(jsonPath("$.baseline.id").value(6)); + + verify(pipelineIterationService).comparePipelineIterations(projectId, 7L, 6L); + } + + @Test + void retryStage_ShouldDelegateToServiceWithUserIdAndReturnAccepted() throws Exception { + var rq = PipelineStageRetryRQ.builder().comment("Retrying after prompt fix").build(); + var rs = PipelineStageRetryRS.builder().stageId(501L).status("Pending").statusCategory(com.epam.reportportal.base.core.aifactory.enums.StatusCategory.PENDING) + .triggeredRunUrl("https://ci/runs/999999").build(); + given(pipelineStageRetryService.retryPipelineIterationStage(projectId, 9L, 7L, 501L, rq)).willReturn(rs); + + mockMvc.perform(post("/v1/project/{projectKey}/pipeline/iteration/{iterationId}/stage/{stageId}/retry", + projectKey, 7L, 501L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.stageId").value(501)) + .andExpect(jsonPath("$.status").value("Pending")) + .andExpect(jsonPath("$.triggeredRunUrl").value("https://ci/runs/999999")); + + verify(pipelineStageRetryService).retryPipelineIterationStage(projectId, 9L, 7L, 501L, rq); + } + + @Test + void reportStage_ShouldDelegateWithTheStageKeyFromThePathAndReturnTheIteration() throws Exception { + var rq = PipelineStageRQ.builder().status("Running").build(); + var rs = PipelineIterationDetailRS.builder().id(7L).build(); + given(pipelineIterationService.reportStage(projectId, 7L, "grade", rq)).willReturn(rs); + + mockMvc.perform(put("/v1/project/{projectKey}/pipeline/iteration/{iterationId}/stage/{stageKey}", + projectKey, 7L, "grade") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(7)); + + verify(pipelineIterationService).reportStage(projectId, 7L, "grade", rq); + } + + @Test + void reportStage_WithoutStatus_ShouldBeRejectedByValidation() throws Exception { + mockMvc.perform(put("/v1/project/{projectKey}/pipeline/iteration/{iterationId}/stage/{stageKey}", + projectKey, 7L, "grade") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + void patchIteration_ShouldDelegateAndReturnTheUpdatedIteration() throws Exception { + var rq = PipelineIterationPatchRQ.builder() + .attributes(List.of(PipelineAttribute.builder().key("env").value("beta5").build())) + .removeAttributes(List.of("jira")).build(); + var rs = PipelineIterationDetailRS.builder().id(7L).build(); + given(pipelineIterationService.patchPipelineIteration(projectId, 7L, rq)).willReturn(rs); + + mockMvc.perform(patch("/v1/project/{projectKey}/pipeline/iteration/{iterationId}", projectKey, 7L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(7)); + + verify(pipelineIterationService).patchPipelineIteration(projectId, 7L, rq); + } + + @Test + void getIterations_ShouldParseStatusAndAttributeFiltersIntoTheFilter() throws Exception { + given(pipelineIterationService.getPipelineIterations(eq(projectId), eq(2L), any(PipelineIterationFilter.class), + any(OffsetRequest.class))).willReturn(new PageImpl(List.of())); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline/{pipelineId}/iteration", projectKey, 2L) + .param("search", "#4") + .param("status", "FAILED,RUNNING") + .param("attribute", "requirement:US-TMS-EXP-002", "ci:#1301188")) + .andExpect(status().isOk()); + + var captor = org.mockito.ArgumentCaptor.forClass(PipelineIterationFilter.class); + verify(pipelineIterationService).getPipelineIterations(eq(projectId), eq(2L), captor.capture(), + any(OffsetRequest.class)); + assertEquals("#4", captor.getValue().search()); + assertEquals(java.util.Set.of(StatusCategory.FAILED, StatusCategory.RUNNING), captor.getValue().statuses()); + assertEquals("US-TMS-EXP-002", captor.getValue().attributes().get("requirement")); + assertEquals("#1301188", captor.getValue().attributes().get("ci")); + } + + @Test + void getIterations_WithMalformedAttributeFilter_ShouldBeRejectedAsBadRequest() { + var thrown = org.junit.jupiter.api.Assertions.assertThrows(Exception.class, + () -> mockMvc.perform(get("/v1/project/{projectKey}/pipeline/{pipelineId}/iteration", projectKey, 2L) + .param("attribute", "no-separator"))); + + var cause = thrown.getCause() != null ? thrown.getCause() : thrown; + org.junit.jupiter.api.Assertions.assertInstanceOf( + com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException.class, cause); + } + + @Test + void retryStage_WithoutBody_ShouldPassNullRequestToService() throws Exception { + var rs = PipelineStageRetryRS.builder().stageId(501L).status("Pending").statusCategory(com.epam.reportportal.base.core.aifactory.enums.StatusCategory.PENDING).build(); + given(pipelineStageRetryService.retryPipelineIterationStage(eq(projectId), eq(9L), eq(7L), eq(501L), isNull())).willReturn(rs); + + mockMvc.perform(post("/v1/project/{projectKey}/pipeline/iteration/{iterationId}/stage/{stageId}/retry", + projectKey, 7L, 501L)) + .andExpect(status().isAccepted()); + + verify(pipelineStageRetryService).retryPipelineIterationStage(eq(projectId), eq(9L), eq(7L), eq(501L), isNull()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIntegrationTest.java new file mode 100644 index 0000000000..1a57db154c --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIntegrationTest.java @@ -0,0 +1,606 @@ +package com.epam.reportportal.base.core.aifactory.controller.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epam.reportportal.base.core.aifactory.connector.CiHttpClient; +import com.epam.reportportal.base.core.aifactory.PipelineTestData; +import com.epam.reportportal.base.core.aifactory.dto.IterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationPatchRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineSettingsRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStepResultRQ; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; +import com.epam.reportportal.base.ws.BaseMvcTest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration tests for {@link com.epam.reportportal.base.core.aifactory.controller.PipelineController}. + * {@link CiHttpClient} is mocked because it is the real outbound-network boundary + * (GitHub Actions/GitLab CI) — everything else (controller, services, repositories, + * mapper, connector) runs for real against the fixture below. + */ +@Sql("/db/aifactory/pipeline/pipeline-fill.sql") +@Sql(scripts = "/db/aifactory/pipeline/pipeline-cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +@ExtendWith(MockitoExtension.class) +class PipelineIntegrationTest extends BaseMvcTest { + + private static final String SUPERADMIN_PROJECT_KEY = "superadmin_personal"; + private static final String BASE = "/v1/project/" + SUPERADMIN_PROJECT_KEY + "/pipeline"; + + @Autowired + private PipelineRepository pipelineRepository; + + @Autowired + private PipelineStageRepository pipelineStageRepository; + + @Autowired + private TmsTestCaseRepository tmsTestCaseRepository; + + @MockBean + private CiHttpClient ciHttpClient; + + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + private static String attribute(JsonNode owner, String key) { + for (var attribute : owner.path("attributes")) { + if (key.equals(attribute.path("key").asText())) { + return attribute.path("value").asText(); + } + } + return null; + } + + private static JsonNode stage(JsonNode iteration, String name) { + for (var stage : iteration.path("stages")) { + if (name.equals(stage.path("name").asText())) { + return stage; + } + } + return null; + } + + private JsonNode body(org.springframework.test.web.servlet.ResultActions actions) throws Exception { + return mapper.readTree(actions.andReturn().getResponse().getContentAsString()); + } + + private static PipelineAttribute main(String key, String value) { + return PipelineAttribute.builder().key(key).value(value).type(AttributeType.META).build(); + } + + // ------------------------------------------------------------------ pipelines + + @Test + void getPipelines_ShouldReturnOnlyThisProjectsPipelinesWithTypeAttributesAndIterationsCount() throws Exception { + mockMvc.perform(get(BASE).with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].id").value(900)) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].type").value("TEST_CASE_GENERATION")) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].iterationsCount").value(2)) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].latestIteration").isEmpty()) + .andExpect(jsonPath("$.content[?(@.name=='other-project-pipeline')]").isEmpty()); + } + + @Test + void updatePipelineSettings_WhenEnablingAutoReadyWithValidThreshold_ShouldPersistAndReturnSettings() + throws Exception { + var rq = PipelineSettingsRQ.builder() + .settings(Map.of("autoReady", Map.of("enabled", true, "threshold", 85))).build(); + + mockMvc.perform(patch(BASE + "/900") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.settings.autoReady.enabled").value(true)) + .andExpect(jsonPath("$.settings.autoReady.threshold").value(85)); + + var pipeline = pipelineRepository.findById(900L).orElseThrow(); + assertEquals(Map.of("autoReady", Map.of("enabled", true, "threshold", 85)), pipeline.getSettings().getValues()); + } + + @Test + void updatePipelineSettings_WhenEnablingAutoReadyWithoutThreshold_ShouldReturnBadRequestAndNotPersist() + throws Exception { + var rq = PipelineSettingsRQ.builder().settings(Map.of("autoReady", Map.of("enabled", true))).build(); + + mockMvc.perform(patch(BASE + "/900") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isBadRequest()); + + assertNull(pipelineRepository.findById(900L).orElseThrow().getSettings()); + } + + // ------------------------------------------------------------------ iteration list + + @Test + void getIterations_ShouldReturnBothIterationsWithTypedAttributesAndCategories() throws Exception { + var list = body(mockMvc.perform(get(BASE + "/900/iteration").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2))); + + var newest = list.get("content").get(0); + var oldest = list.get("content").get(1); + assertEquals(2, newest.get("iterationNumber").asInt()); + assertEquals("WAITING", newest.get("statusCategory").asText()); + assertEquals("US-TMS-EXP-002", attribute(newest, "requirement")); + assertEquals("beta5", attribute(oldest, "env")); + assertEquals("META", oldest.get("attributes").get(0).get("type").asText()); + assertEquals("SCORE", oldest.get("attributes").get(2).get("valueType").asText()); + assertEquals("LINK", oldest.get("attributes").get(5).get("valueType").asText()); + } + + @Test + void getIterations_WithFilters_ShouldNarrowTheList() throws Exception { + var url = BASE + "/900/iteration"; + + mockMvc.perform(get(url).param("search", "booking pack retry").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].iterationNumber").value(2)); + mockMvc.perform(get(url).param("search", "#1").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].iterationNumber").value(1)); + mockMvc.perform(get(url).param("search", "%").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(0)); + mockMvc.perform(get(url).param("status", "WAITING").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].iterationNumber").value(2)); + mockMvc.perform(get(url).param("status", "WAITING,PASSED").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(2)); + mockMvc.perform(get(url).param("attribute", "env:beta5").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].iterationNumber").value(1)); + mockMvc.perform(get(url).param("attribute", "requirement:us-tms-exp-002").param("attribute", "env:beta5") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(1)); + mockMvc.perform(get(url).param("attribute", "env:beta").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(0)); + mockMvc.perform(get(url).param("attribute", "env:beta5").param("status", "WAITING") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.page.totalElements").value(0)); + } + + @Test + void getPipelines_WithSearch_ShouldMatchPipelineNameOrAnyOfItsIterations() throws Exception { + mockMvc.perform(get(BASE).param("search", "EXP-002").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("factory-tc-gen")); + mockMvc.perform(get(BASE).param("search", "TC-GEN").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content[0].name").value("factory-tc-gen")); + mockMvc.perform(get(BASE).param("search", "no-such-thing").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(0)); + } + + // ------------------------------------------------------------------ reporting + + @Test + void createIteration_ForNewPipelineName_ShouldCreatePipelineWithTypeAndLinkTestCases() throws Exception { + var rq = PipelineIterationRQ.builder().name("brand-new-pipeline").type("TEST_AUTOMATION").attributes(List.of(main("repo", "org/auto"))).iteration(IterationRQ.builder().attributes(List.of(main("trigger", "CI - smoke pack"))).stages(List.of(PipelineStageRQ.builder() + .stageKey("stage-gen-tc") + .status("Passed") + .attributes(List.of(PipelineAttribute.builder().key("agent").value("create-test-cases@0.4").build())) + .sections(PipelineTestData.sectionLinking(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), new PipelineSection.EntityReference("TEST_CASE", "TC-404")))) + .build())).build()).build(); + + var detail = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.pipelineName").value("brand-new-pipeline")) + .andExpect(jsonPath("$.pipelineType").value("TEST_AUTOMATION")) + .andExpect(jsonPath("$.iterationNumber").value(1)) + .andExpect(jsonPath("$.statusCategory").value("PASSED")) + .andExpect(jsonPath("$.warnings[0].code").value("RELATED_ENTITY_NOT_FOUND")) + .andExpect(jsonPath("$.warnings[0].entityIds[0]").value("TC-404"))); + + assertEquals("CI - smoke pack", attribute(detail, "trigger")); + var createdPipeline = pipelineRepository.findByProjectIdAndName(1L, "brand-new-pipeline").orElseThrow(); + assertNull(createdPipeline.getSettings()); + assertEquals("TEST_AUTOMATION", createdPipeline.getType()); + assertEquals("repo", createdPipeline.getAttributes().getItems().get(0).getKey()); + } + + @Test + void createIteration_WithADifferentPipelineTypeThanRecorded_ShouldBeBadRequest() throws Exception { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").type("TEST_AUTOMATION").iteration(IterationRQ.builder().build()).build(); + + mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isBadRequest()); + } + + @Test + void createIteration_WithIdempotencyKey_ShouldReplaySameBodyAndRejectDifferentBody() throws Exception { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Passed").build())).build()).build(); + var other = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Failed").build())).build()).build(); + var url = BASE + "/iteration"; + + var first = mockMvc.perform(post(url) + .header("Idempotency-Key", "ci-run-1001") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + var firstId = mapper.readTree(first).get("id").asLong(); + + mockMvc.perform(post(url) + .header("Idempotency-Key", "ci-run-1001") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(header().string("Idempotent-Replayed", "true")) + .andExpect(jsonPath("$.id").value(firstId)); + + mockMvc.perform(post(url) + .header("Idempotency-Key", "ci-run-1001") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(other)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isConflict()); + } + + @Test + void createIteration_ForExistingPipelineNameWithRerunOf_ShouldIncrementNumberAndLinkTheSource() throws Exception { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().rerunOfIterationId(901L).build()).build(); + + mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.pipelineId").value(900)) + .andExpect(jsonPath("$.iterationNumber").value(3)) + .andExpect(jsonPath("$.rerunOf.id").value(901)) + .andExpect(jsonPath("$.rerunOf.iterationNumber").value(2)); + } + + @Test + void createIteration_WithNestedStageSectionsAndResultPointer_ShouldRoundTripTreeTablesAndResult() throws Exception { + var rq = PipelineIterationRQ.builder().name("brand-new-automation-pipeline").iteration(IterationRQ.builder().stages(List.of( + PipelineStageRQ.builder() + .stageKey("automate") + .status("Passed") + .result(PipelineStepResultRQ.builder().resultType("LAUNCH").resultRef("42").build()) + .sections(List.of(PipelineSection.builder().key("usage").title("Token usage") + .columns(List.of(PipelineSection.Column.builder().key("model").label("Model").build(), + PipelineSection.Column.builder().key("cost").label("Cost") + .valueType(AttributeValueType.MONEY).build())) + .rows(List.of(PipelineSection.Row.builder().key("auto") + .cells(Map.of("model", PipelineSection.Cell.builder().value("auto").build(), + "cost", PipelineSection.Cell.builder().value("0.31").build())).build())) + .build())) + .build(), + PipelineStageRQ.builder() + .stageKey("run-suite") + .parentStageKey("automate") + .status("Passed") + .build())).build()).build(); + + var detail = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated())); + + var automate = stage(detail, "automate"); + var runSuite = stage(detail, "run-suite"); + assertNotNull(automate); + assertNotNull(runSuite); + assertEquals("LAUNCH", automate.get("result").get("resultType").asText()); + assertEquals("0.31", automate.get("sections").get(0).get("rows").get(0).get("cells").get("cost").get("value").asText()); + assertFalse(automate.has("parentStageId")); + assertEquals(automate.get("id").asLong(), runSuite.get("parentStageId").asLong()); + + var childStage = pipelineStageRepository.findById(runSuite.get("id").asLong()).orElseThrow(); + assertEquals(automate.get("id").asLong() + "." + childStage.getId(), childStage.getPath()); + } + + @Test + void createIteration_WithBareScalarCells_ShouldAcceptThem() throws Exception { + var json = """ + {"name": "scalar-cells", + "iteration": {"stages": [{"stageKey": "grade", "status": "Passed", + "sections": [{"key": "grades", "columns": [{"key": "score", "valueType": "SCORE"}], + "rows": [{"key": "r1", "cells": {"score": 90}}]}]}]}} + """; + + var detail = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(json) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated())); + + assertEquals("90", stage(detail, "grade").get("sections").get(0).get("rows").get(0).get("cells") + .get("score").get("value").asText()); + } + + @Test + void reportStage_ShouldReportAStageStepByStepAndKeepTheIterationStatusInStep() throws Exception { + var created = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().name("incremental").iteration(IterationRQ.builder().build()).build())) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.statusCategory").value("PENDING"))); + var iterationId = created.get("id").asLong(); + var url = BASE + "/iteration/" + iterationId + "/stage/create"; + + mockMvc.perform(put(url).contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineStageRQ.builder().status("Running").build())) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCategory").value("RUNNING")) + .andExpect(jsonPath("$.stages[0].name").value("create")); + + mockMvc.perform(put(url).contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineStageRQ.builder().status("Passed") + .attributes(List.of(main("cases", "3"))).build())) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.statusCategory").value("PASSED")) + .andExpect(jsonPath("$.stages.length()").value(1)) + .andExpect(jsonPath("$.stages[0].attempt").value(1)); + } + + @Test + void reportStage_OfAFinishedStageByAnotherJob_ShouldStartANewAttemptAndKeepTheFinishedOne() throws Exception { + var created = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().name("attempts").iteration(IterationRQ.builder().build()).build())) + .with(token(oAuthHelper.getSuperadminToken())))); + var url = BASE + "/iteration/" + created.get("id").asLong() + "/stage/upload"; + + report(url, "Failed", "job-1"); + var detail = report(url, "Running", "job-2"); + + var upload = stage(detail, "upload"); + assertEquals(2, upload.get("attempt").asInt()); + assertEquals(1, upload.get("attempts").size()); + assertEquals("Failed", upload.get("attempts").get(0).get("status").asText()); + } + + private JsonNode report(String url, String status, String jobId) throws Exception { + var rq = PipelineStageRQ.builder().status(status) + .execution(PipelineExecution.builder().executorType("GITLAB_CI").externalRunId(jobId) + .runUrl("https://gitlab.example/jobs/" + jobId).build()) + .build(); + return body(mockMvc.perform(put(url).contentType(APPLICATION_JSON).content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk())); + } + + @Test + void reportStage_ForAStageWithAnUnknownStatusText_ShouldKeepTheTextAndShowItNeutrally() throws Exception { + var created = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().name("unknown-status").iteration(IterationRQ.builder().build()).build())) + .with(token(oAuthHelper.getSuperadminToken())))); + + mockMvc.perform(put(BASE + "/iteration/" + created.get("id").asLong() + "/stage/grade") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineStageRQ.builder().status("Queued · waiting for runner").build())) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stages[0].status").value("Queued · waiting for runner")) + .andExpect(jsonPath("$.stages[0].statusCategory").value(StatusCategory.UNKNOWN.name())); + } + + @Test + void generationPipeline_ShouldGetAReviewStageMaintainedByReportPortalAndFailedGradeShouldNotFailTheIteration() + throws Exception { + var draft = tmsTestCaseRepository.findById(901L).orElseThrow(); + draft.setStatus(TmsTestCaseStatus.DRAFT); + tmsTestCaseRepository.saveAndFlush(draft); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of( + PipelineStageRQ.builder().stageKey("create").status("Passed").build(), + PipelineStageRQ.builder().stageKey("grade").status("Failed").affectsIterationStatus(false).build(), + PipelineStageRQ.builder().stageKey("upload").status("Passed") + .sections(PipelineTestData.sectionLinking(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), + new PipelineSection.EntityReference("TEST_CASE", "TC-901")))).build())).build()).build(); + + var detail = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("In review")) + .andExpect(jsonPath("$.statusCategory").value("WAITING"))); + + var review = stage(detail, "Review"); + assertNotNull(review); + assertEquals("REPORTPORTAL", review.get("ownedBy").asText()); + assertEquals("In progress", review.get("status").asText()); + assertEquals("1/2", attribute(review, "review")); + } + + @Test + void reportStage_ForTheReviewStageMaintainedByReportPortal_ShouldBeRejected() throws Exception { + var created = body(mockMvc.perform(post(BASE + "/iteration") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build())) + .with(token(oAuthHelper.getSuperadminToken())))); + + mockMvc.perform(put(BASE + "/iteration/" + created.get("id").asLong() + "/stage/review") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(PipelineStageRQ.builder().status("Done").build())) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isBadRequest()); + } + + @Test + void patchIteration_ShouldAddAndRemoveAttributesAndBeFoundBySearchAfterwards() throws Exception { + var rq = PipelineIterationPatchRQ.builder() + .attributes(List.of(PipelineAttribute.builder().key("model").value("claude-opus").build())) + .removeAttributes(List.of("env")).build(); + + var detail = body(mockMvc.perform(patch(BASE + "/iteration/900") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk())); + + assertEquals("claude-opus", attribute(detail, "model")); + assertEquals(null, attribute(detail, "env")); + mockMvc.perform(get(BASE + "/900/iteration").param("search", "claude-opus") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].iterationNumber").value(1)); + } + + // ------------------------------------------------------------------ detail & compare + + @Test + void getIterationDetail_ShouldReturnStagesWithSectionsAsReported() throws Exception { + mockMvc.perform(get(BASE + "/iteration/900").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(900)) + .andExpect(jsonPath("$.pipelineType").value("TEST_CASE_GENERATION")) + .andExpect(jsonPath("$.stages[0].name").value("MD -> Test cases")) + .andExpect(jsonPath("$.stages[0].status").value("Passed")) + .andExpect(jsonPath("$.stages[0].sections[0].key").value("grades")) + .andExpect(jsonPath("$.stages[0].sections[0].rows[0].cells.case.target.entityId").value("TC-900")); + } + + @Test + void getIterationDetail_ShouldExposeLinksAndRetryableFlagButNotExecutionInternals() throws Exception { + mockMvc.perform(get(BASE + "/iteration/901").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.stages[0].links[0].type").value("RUN")) + .andExpect(jsonPath("$.stages[0].links[0].url").value("https://github.com/org/repo/actions/runs/123456")) + .andExpect(jsonPath("$.stages[0].retryable").value(true)) + .andExpect(jsonPath("$.stages[0].execution").doesNotExist()) + .andExpect(jsonPath("$.stages[1].links").doesNotExist()); + } + + @Test + void compareIterations_SameRequirement_ShouldReturnMetricDeltasAndRowsSideBySide() throws Exception { + var compare = body(mockMvc.perform(get(BASE + "/iteration/901/compare").param("with", "900") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.candidate.id").value(901)) + .andExpect(jsonPath("$.baseline.id").value(900)) + .andExpect(jsonPath("$.sameScope").value(true))); + + JsonNode suiteScore = null; + JsonNode cost = null; + for (var metric : compare.get("metrics")) { + if ("suiteScore".equals(metric.get("key").asText())) { + suiteScore = metric; + } else if ("cost".equals(metric.get("key").asText())) { + cost = metric; + } + } + assertNotNull(suiteScore); + assertEquals("-14", suiteScore.get("delta").asText()); + assertEquals("WORSE", suiteScore.get("verdict").asText()); + assertEquals("0.37", cost.get("delta").asText()); + assertEquals("WORSE", cost.get("verdict").asText()); + } + + @Test + void compareIterations_DifferentPipelineDefinitions_ShouldReturnBadRequest() throws Exception { + mockMvc.perform(get(BASE + "/iteration/900/compare").param("with", "920") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isBadRequest()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("same pipeline definition"))); + } + + // ------------------------------------------------------------------ retry + + /** + * {@code PipelineStageRetryServiceImpl} deliberately does its DB reads/writes in two + * independent {@code PROPAGATION_REQUIRES_NEW} transactions (see its class javadoc), so they + * run on their own connections and commit independently of whatever wraps the request. That + * collides with {@link BaseMvcTest}'s default test-managed transaction (rolled back, never + * committed): a REQUIRES_NEW transaction opened on another connection cannot see fixture rows + * inserted by a transaction that was never committed. Running these tests without the test's + * own wrapping transaction lets the {@code @Sql} fixture commit for real, so the retry + * service's transactions can see it; {@code pipeline-cleanup.sql} (AFTER_TEST_METHOD) removes + * what would otherwise leak into later tests, since there is no rollback safety net here. + */ + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void retryStage_WhenRetryable_ShouldTriggerConnectorAndSetStagePending() throws Exception { + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + var rq = PipelineStageRetryRQ.builder().comment("Retrying after prompt fix").build(); + + mockMvc.perform(post(BASE + "/iteration/901/stage/901/retry") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.stageId").value(901)) + .andExpect(jsonPath("$.status").value("Pending")) + .andExpect(jsonPath("$.statusCategory").value("PENDING")) + .andExpect(jsonPath("$.triggeredRunUrl").value("https://github.com/org/repo/actions/workflows/ai-pipeline.yml")); + + var stage = pipelineStageRepository.findById(901L).orElseThrow(); + assertEquals(StatusCategory.PENDING, stage.getStatusCategory()); + assertTrue(stage.getLastRetriedAt() != null); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void retryStage_WhenStageNotRetryable_ShouldReturnBadRequestWithoutCallingConnector() throws Exception { + mockMvc.perform(post(BASE + "/iteration/900/stage/900/retry") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isBadRequest()); + + org.mockito.Mockito.verifyNoInteractions(ciHttpClient); + var stage = pipelineStageRepository.findById(900L).orElseThrow(); + assertEquals(StatusCategory.PASSED, stage.getStatusCategory()); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void retryStage_WhenStageBelongsToDifferentIteration_ShouldReturnNotFound() throws Exception { + mockMvc.perform(post(BASE + "/iteration/900/stage/901/retry") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIterationE2eIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIterationE2eIntegrationTest.java new file mode 100644 index 0000000000..841675135e --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIterationE2eIntegrationTest.java @@ -0,0 +1,740 @@ +package com.epam.reportportal.base.core.aifactory.controller.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epam.reportportal.base.ws.BaseMvcTest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.TimeZone; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * The end-to-end scenario of {@code doc/pipeline-iteration-e2e-journey.md}, request by request, against the real + * application: pipeline "Test case generation", iteration #1 with the stages Create, Grade, Upload and the + * Review stage kept by ReportPortal, four test cases of which Auto-Ready promotes two, and an editor who + * marks the other two Ready. + *

+ * Every step is one private method named after the step of the document, so a failure points at the + * document. The test cases are created through the Test Case Library API like the agent does, so the Library + * ids in the reports are the real ones. The test commits (the Library announces a change to the pipeline only + * once it is committed), and the cleanup script removes what it created. + */ +@Sql("/db/aifactory/e2e/pipeline-e2e-fill.sql") +@Sql(scripts = "/db/aifactory/e2e/pipeline-e2e-cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +class PipelineIterationE2eIntegrationTest extends BaseMvcTest { + + private static final String PROJECT = "superadmin_personal"; + private static final String PIPELINES = "/v1/project/" + PROJECT + "/pipeline"; + private static final String TEST_CASES = "/v1/project/" + PROJECT + "/tms/test-case"; + private static final long LIBRARY_FOLDER_ID = 910; + private static final String RUN_URL = "https://gitlab.epam.com/epm-rpp/rp-tests/-/jobs/"; + + /** A case of the scenario: what the agent authored, how the grader scored it, and what it criticised. */ + private record AuthoredCase(String name, String priority, int[] scores, String[] reasons) { + + int total() { + int sum = 0; + for (int score : scores) { + sum += score; + } + return sum; + } + } + + private static final String[] CRITERIA = {"atomicity", "clear_steps", "clear_expected_results", "no_invented_logic", + "no_invented_ui", "coherence"}; + private static final String[] CRITERION_LABELS = {"Atomicity", "Clear steps", "Clear expected results", + "No invented logic", "No invented UI", "Coherence"}; + private static final int[] CRITERION_MAX = {15, 20, 20, 20, 15, 10}; + + private static final List CASES = List.of( + new AuthoredCase("Test Case Library. Empty state. Empty state is displayed for a project without Test Cases", + "HIGH", new int[] {15, 20, 18, 18, 15, 8}, + new String[] {null, null, "Step 3 does not name the message that must be visible", + "Step 2 assumes a default project exists", null, "Title is broader than the steps"}), + new AuthoredCase("Test Case Library. Empty state. 'Create Test Case' button opens the creation modal", + "MEDIUM", new int[] {15, 20, 17, 20, 11, 8}, + new String[] {null, null, "Modal title is not stated", null, "Button label is not in the requirement", + "Title is broader than the steps"}), + new AuthoredCase("Test Case Library. Navigation panel. Folder tree is hidden while the Library is empty", + "LOW", new int[] {15, 12, 14, 13, 10, 8}, + new String[] {null, "Two actions in one step", "Expected result is not observable", + "Assumes folders can exist in an empty Library", "Panel name is not in the requirement", + "Title is broader than the steps"}), + new AuthoredCase("Test Case Library. Empty state. 'Import' button opens the import modal", + "LOW", new int[] {8, 12, 10, 10, 10, 8}, + new String[] {"Covers button and modal content", "Step 1 is vague", "No expected result for the modal", + "Invents a file type check", "Invents a drop zone", "Title is broader than the steps"})); + + private static TimeZone originalTimeZone; + + /** + * The shared {@code JpaInstantConverter} writes UTC and reads in the JVM zone, so a machine that is not on UTC + * shifts an iteration's times every time it is saved again. Production runs on UTC; so does this test. + */ + @BeforeAll + static void runOnUtc() { + originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @AfterAll + static void restoreTimeZone() { + TimeZone.setDefault(originalTimeZone); + } + + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + /** Library display ids of the four cases, known once step 10 created them (the Library makes them). */ + private final List libraryIds = new ArrayList<>(); + private final List libraryEntityIds = new ArrayList<>(); + + private long pipelineId; + private long iterationId; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void fullScenario_FromTheFirstReportToAllTestCasesReady() throws Exception { + step1_ciStartsAndCreatesThePipelineAndIteration(); + step2_pipelinesPageShowsTheRunningIteration(); + step3_iterationCardsOfThePipeline(); + step4_iterationDetailsWhileCreateRuns(); + step5_editorSwitchesAutoReadyOn(); + step6_createFinishes(); + step7_gradeStarts(); + step8_gradeFinishesWithTheRawOutputAndTheTable(); + step9_suiteScoreGoesToTheIteration(); + step10_agentWritesTheTestCasesToTheLibrary(); + step11_uploadStarts(); + step12_uploadFinishesAndAutoReadyAndReviewComeAlive(); + step13_libraryIdsGoBackIntoCreateAndGradeTables(); + step14_ciPipelineEnds(); + step15_whatTheUiReadsNow(); + editorMarksTheOtherTwoCasesReady_IterationCompletes(); + } + + // ------------------------------------------------------------------ step 1 + + private void step1_ciStartsAndCreatesThePipelineAndIteration() throws Exception { + var rq = """ + { + "name": "Test case generation", + "type": "TEST_CASE_GENERATION", + "attributes": [ + {"key": "repository", "label": "Repository", "value": "EPM-RPP/rp-tests", "type": "META", + "valueType": "LINK", "url": "https://gitlab.epam.com/epm-rpp/rp-tests"} + ], + "iteration": { + "startedAt": "2026-09-19T10:13:00Z", + "attributes": [ + {"key": "requirement", "label": "Requirement", "value": "US-TMS-MIG-001", + "description": "Test Case Library empty state display and navigation panel", + "type": "META", "order": 1, "comparisonKey": true}, + {"key": "trigger", "label": "Trigger", "value": "Web form · REQUIREMENT", "type": "META", "order": 2}, + {"key": "model", "label": "Model", "value": "auto (default)", "type": "META", "order": 3}, + {"key": "env", "label": "Environment", "value": "beta5", "type": "TAG"}, + {"key": "spec", "label": "Spec", "value": "US-TMS-MIG-001", "type": "TAG"}, + {"key": "jira", "label": "Jira", "value": "EPMRPP-104212", "type": "TAG", "valueType": "LINK", + "url": "https://jira.epam.com/browse/EPMRPP-104212"}, + {"key": "ci", "label": "CI pipeline", "value": "#1284551", "type": "TAG", "valueType": "LINK", + "url": "https://gitlab.epam.com/epm-rpp/rp-tests/-/pipelines/1284551"}, + {"key": "folder", "label": "Library folder", "value": "tms/library/emptystate/iteration-1", "type": "TAG"} + ], + "stages": [ + {"stageKey": "create", "name": "Create", "sequence": 1, "status": "Running", + "startedAt": "2026-09-19T10:13:00Z", "message": "Authoring test cases from US-TMS-MIG-001", + "attributes": [{"key": "model", "label": "Model", "value": "auto (default)", "type": "NONE"}], + "execution": %s}, + {"stageKey": "grade", "name": "Grade", "sequence": 2, "status": "Pending", + "affectsIterationStatus": false, "message": "Waiting for Create"}, + {"stageKey": "upload", "name": "Upload", "sequence": 3, "status": "Pending", + "message": "Waiting for Grade"} + ] + } + } + """.formatted(execution("8812301")); + + var detail = send(post(PIPELINES + "/iteration").header("Idempotency-Key", "gitlab-pipeline-1284551"), rq, + HttpStatus.CREATED); + + iterationId = detail.get("id").asLong(); + pipelineId = detail.get("pipelineId").asLong(); + assertEquals("Test case generation", detail.get("pipelineName").asText()); + assertEquals("TEST_CASE_GENERATION", detail.get("pipelineType").asText()); + assertEquals(1, detail.get("iterationNumber").asInt()); + assertIterationStatus(detail, "Running", "RUNNING"); + assertEquals("2026-09-19T10:13:00Z", detail.get("startedAt").asText(), "step1 startedAt"); + assertEquals(8, detail.get("attributes").size()); + assertEquals("US-TMS-MIG-001", attribute(detail, "requirement")); + assertTrue(detail.get("attributes").get(0).get("comparisonKey").asBoolean()); + assertNotNull(detail.get("startedBy")); + assertEquals(4, detail.get("stages").size()); + + var create = stage(detail, "Create"); + assertStage(create, "Running", "RUNNING", "PIPELINE", true); + assertEquals(RUN_URL + "8812301", create.get("links").get(0).get("url").asText()); + assertEquals("RUN", create.get("links").get(0).get("type").asText()); + assertTrue(create.get("retryable").asBoolean()); + assertStage(stage(detail, "Grade"), "Pending", "PENDING", "PIPELINE", false); + assertStage(stage(detail, "Upload"), "Pending", "PENDING", "PIPELINE", true); + var review = stage(detail, "Review"); + assertStage(review, "Pending", "PENDING", "REPORTPORTAL", false); + assertEquals("Waiting for Upload", review.get("message").asText()); + } + + // ------------------------------------------------------------------ steps 2-4 + + private void step2_pipelinesPageShowsTheRunningIteration() throws Exception { + var page = send(get(PIPELINES).param("offset", "0").param("limit", "100"), null, HttpStatus.OK); + + var pipeline = page.get("content").get(0); + assertEquals(1, page.get("content").size()); + assertEquals("Test case generation", pipeline.get("name").asText()); + assertEquals("TEST_CASE_GENERATION", pipeline.get("type").asText()); + assertEquals("EPM-RPP/rp-tests", attribute(pipeline, "repository")); + assertEquals(1, pipeline.get("iterationsCount").asInt()); + assertFalse(pipeline.has("settings"), "Auto-Ready is not switched on yet"); + assertFalse(pipeline.has("latestIteration"), "iterations are read by expanding the group (step 3)"); + } + + private void step3_iterationCardsOfThePipeline() throws Exception { + var cards = send(get(PIPELINES + "/" + pipelineId + "/iteration").param("offset", "0").param("limit", "100"), + null, HttpStatus.OK); + assertEquals(1, cards.get("content").size()); + assertEquals(iterationId, cards.get("content").get(0).get("id").asLong()); + + assertEquals(1, send(get(PIPELINES + "/" + pipelineId + "/iteration").param("search", "US-TMS-MIG"), null, + HttpStatus.OK).get("content").size()); + assertEquals(1, send(get(PIPELINES + "/" + pipelineId + "/iteration").param("status", "RUNNING"), null, + HttpStatus.OK).get("content").size()); + assertEquals(1, send(get(PIPELINES + "/" + pipelineId + "/iteration") + .param("attribute", "requirement:US-TMS-MIG-001"), null, HttpStatus.OK).get("content").size()); + } + + private void step4_iterationDetailsWhileCreateRuns() throws Exception { + var detail = iteration(); + assertIterationStatus(detail, "Running", "RUNNING"); + assertEquals("2026-09-19T10:13:00Z", detail.get("startedAt").asText(), "step4 startedAt"); + assertStage(stage(detail, "Create"), "Running", "RUNNING", "PIPELINE", true); + assertEquals("Authoring test cases from US-TMS-MIG-001", stage(detail, "Create").get("message").asText()); + } + + // ------------------------------------------------------------------ step 5 + + private void step5_editorSwitchesAutoReadyOn() throws Exception { + var pipeline = send(patch(PIPELINES + "/" + pipelineId), + "{\"settings\": {\"autoReady\": {\"enabled\": true, \"threshold\": 90}}}", HttpStatus.OK); + + assertTrue(pipeline.get("settings").get("autoReady").get("enabled").asBoolean()); + assertEquals(90, pipeline.get("settings").get("autoReady").get("threshold").asInt()); + send(patch(PIPELINES + "/" + pipelineId), "{\"settings\": {\"autoReady\": {\"enabled\": true}}}", + HttpStatus.BAD_REQUEST); + } + + // ------------------------------------------------------------------ step 6 + + private void step6_createFinishes() throws Exception { + var detail = reportStage("create", createStageBody(false)); + + assertIterationStatus(detail, "Running", "RUNNING"); + var create = stage(detail, "Create"); + assertStage(create, "Passed", "PASSED", "PIPELINE", true); + assertEquals(362000, create.get("durationMillis").asLong()); + assertEquals("4 cases", create.get("summary").asText()); + assertEquals("4 Test Cases authored", create.get("message").asText()); + assertEquals(2, create.get("sections").size()); + assertEquals("RESULT", create.get("sections").get(0).get("role").asText()); + assertEquals("DETAILS", create.get("sections").get(1).get("role").asText()); + assertEquals("Passed", cell(create, 0, 0, "status")); + assertNull(cellNode(create, 0, 0, "libraryId"), "the cases are not in the Library yet"); + assertEquals("4", attribute(detail, "testCases"), "summed from the stages"); + assertMoney("0.94", attribute(detail, "cost")); + assertStage(stage(detail, "Grade"), "Pending", "PENDING", "PIPELINE", false); + } + + // ------------------------------------------------------------------ steps 7-9 + + private void step7_gradeStarts() throws Exception { + var detail = reportStage("grade", """ + {"name": "Grade", "status": "Running", "affectsIterationStatus": false, + "startedAt": "2026-09-19T10:19:10Z", "message": "Grading 4 Test Cases", "execution": %s} + """.formatted(execution("8812317"))); + + var grade = stage(detail, "Grade"); + assertStage(grade, "Running", "RUNNING", "PIPELINE", false); + assertEquals("Grading 4 Test Cases", grade.get("message").asText()); + assertEquals(RUN_URL + "8812317", grade.get("links").get(0).get("url").asText()); + assertTrue(grade.get("retryable").asBoolean()); + assertIterationStatus(detail, "Running", "RUNNING"); + } + + private void step8_gradeFinishesWithTheRawOutputAndTheTable() throws Exception { + var detail = reportStage("grade", gradeStageBody(false)); + + var grade = stage(detail, "Grade"); + assertStage(grade, "Passed", "PASSED", "PIPELINE", false); + assertEquals(151000, grade.get("durationMillis").asLong()); + assertEquals("79", grade.get("summary").asText()); + assertEquals("79", attribute(grade, "suiteScore")); + assertEquals("grade", grade.get("rawOutputSchemaName").asText()); + assertEquals(79, grade.get("rawOutput").get("total_score").asInt()); + var firstRow = grade.get("sections").get(0).get("rows").get(0); + assertEquals("94", firstRow.get("cells").get("total").get("value").asText()); + assertEquals(3, firstRow.get("details").size(), "one block per criterion that lost points"); + assertEquals("Clear expected results 18/20", firstRow.get("details").get(0).get("heading").asText()); + assertMoney("1.25", attribute(detail, "cost")); + assertIterationStatus(detail, "Running", "RUNNING"); + } + + private void step9_suiteScoreGoesToTheIteration() throws Exception { + var detail = send(patch(PIPELINES + "/iteration/" + iterationId), """ + {"attributes": [{"key": "suiteScore", "label": "Suite score", "value": "79", "type": "META", + "valueType": "SCORE", "order": 5, "direction": "HIGHER_BETTER"}]} + """, HttpStatus.OK); + + assertEquals("79", attribute(detail, "suiteScore")); + assertEquals("SCORE", Objects.requireNonNull(attributeNode(detail, "suiteScore")).get("valueType").asText()); + assertEquals("HIGHER_BETTER", Objects.requireNonNull(attributeNode(detail, "suiteScore")).get("direction").asText()); + assertEquals("folder", detail.get("attributes").get(7).get("key").asText(), "the reported attributes keep their place"); + assertIterationStatus(detail, "Running", "RUNNING"); + } + + // ------------------------------------------------------------------ step 10 + + private void step10_agentWritesTheTestCasesToTheLibrary() throws Exception { + for (var authored : CASES) { + var created = send(post(TEST_CASES), """ + {"name": "%s", "priority": "%s", "testFolderId": %d, "status": "DRAFT"} + """.formatted(authored.name(), authored.priority(), LIBRARY_FOLDER_ID), HttpStatus.OK); + var id = created.get("id").asLong(); + libraryEntityIds.add(id); + libraryIds.add(created.get("displayId").asText()); + assertEquals("DRAFT", created.get("status").asText()); + + var scores = mapper.createArrayNode(); + for (int i = 0; i < CRITERIA.length; i++) { + scores.addObject().put("criterionKey", CRITERIA[i]).put("score", authored.scores()[i]); + } + var generation = mapper.createObjectNode(); + generation.set("qualityScores", scores); + generation.putObject("generation").put("model", "auto (default)").put("costUsd", 0.235); + var scored = send(post(TEST_CASES + "/" + id + "/generation"), generation.toString(), HttpStatus.OK); + assertEquals("AI", scored.get("origin").asText(), "posting scores is what makes the case an AI case"); + assertEquals("DRAFT", scored.get("status").asText()); + assertEquals(authored.total(), scored.get("metrics").get("overallScore").asInt()); + } + assertEquals(4, libraryIds.stream().distinct().count()); + } + + // ------------------------------------------------------------------ steps 11-12 + + private void step11_uploadStarts() throws Exception { + var detail = reportStage("upload", """ + {"name": "Upload", "status": "Running", "startedAt": "2026-09-19T10:21:50Z", + "message": "Uploading 4 Test Cases to the Library", "execution": %s} + """.formatted(execution("8812333"))); + + assertStage(stage(detail, "Upload"), "Running", "RUNNING", "PIPELINE", true); + assertEquals(RUN_URL + "8812333", stage(detail, "Upload").get("links").get(0).get("url").asText()); + assertIterationStatus(detail, "Running", "RUNNING"); + } + + private void step12_uploadFinishesAndAutoReadyAndReviewComeAlive() throws Exception { + var detail = reportStage("upload", uploadStageBody()); + + var upload = stage(detail, "Upload"); + assertStage(upload, "Passed", "PASSED", "PIPELINE", true); + assertEquals("4 ↑", upload.get("summary").asText()); + assertEquals(14000, upload.get("durationMillis").asLong()); + assertEquals(4, upload.get("relatedEntities").size(), "collected from the cell targets by ReportPortal"); + assertEquals("TEST_CASE", upload.get("relatedEntities").get(0).get("entityType").asText()); + assertEquals(libraryIds.getFirst(), upload.get("relatedEntities").get(0).get("entityId").asText()); + var firstLink = cellNode(upload, 0, 0, "testCase"); + assertNotNull(firstLink); + assertEquals(libraryIds.getFirst(), firstLink.get("value").asText()); + assertEquals(libraryIds.getFirst(), firstLink.get("target").get("entityId").asText()); + assertEquals("2", attribute(upload, "autoReadyPromoted")); + assertEquals("90", attribute(upload, "autoReadyThreshold")); + assertFalse(detail.has("warnings") && !detail.get("warnings").isEmpty(), "every case exists in the Library"); + + assertEquals("READY", libraryCase(0).get("status").asText(), "94 >= 90"); + assertEquals("READY", libraryCase(1).get("status").asText(), "91 >= 90"); + assertEquals("DRAFT", libraryCase(2).get("status").asText(), "72 < 90"); + assertEquals("DRAFT", libraryCase(3).get("status").asText(), "58 < 90"); + + var review = stage(detail, "Review"); + assertStage(review, "In progress", "WAITING", "REPORTPORTAL", true); + assertEquals("2/4 Ready", review.get("summary").asText()); + assertEquals("2/4", attribute(review, "review")); + assertEquals("2", attribute(review, "ready")); + assertEquals("4", attribute(review, "total")); + assertEquals("2 Draft Test Cases wait for review in the Test Case Library", review.get("message").asText()); + assertNotNull(review.get("startedAt")); + + assertIterationStatus(detail, "In review", "WAITING"); + assertEquals("2", attribute(detail, "readyNow")); + assertEquals("2", attribute(detail, "autoReadyPromoted")); + assertEquals("4", attribute(detail, "testCases")); + assertMoney("1.27", attribute(detail, "cost")); + } + + // ------------------------------------------------------------------ steps 13-15 + + private void step13_libraryIdsGoBackIntoCreateAndGradeTables() throws Exception { + var create = stage(reportStage("create", createStageBody(true)), "Create"); + var grade = stage(reportStage("grade", gradeStageBody(true)), "Grade"); + + for (int i = 0; i < 4; i++) { + var libraryId = cellNode(create, 0, i, "libraryId"); + assertNotNull(libraryId); + assertEquals(libraryIds.get(i), libraryId.get("value").asText()); + assertEquals("TEST_CASE", libraryId.get("target").get("entityType").asText()); + assertEquals(libraryIds.get(i), libraryId.get("target").get("entityId").asText()); + assertEquals(libraryIds.get(i), Objects.requireNonNull(cellNode(grade, 0, i, "testCase")).get("target").get("entityId").asText()); + } + assertEquals(1, create.get("attempt").asInt(), "the same job: the current attempt is updated"); + assertEquals(1, grade.get("attempt").asInt()); + + var detail = iteration(); + assertIterationStatus(detail, "In review", "WAITING"); + assertStage(stage(detail, "Upload"), "Passed", "PASSED", "PIPELINE", true); + assertEquals("2/4", attribute(stage(detail, "Review"), "review"), "no status changed, nothing was promoted twice"); + } + + private void step14_ciPipelineEnds() throws Exception { + var detail = send(patch(PIPELINES + "/iteration/" + iterationId), "{\"finishedAt\": \"2026-09-19T10:22:40Z\"}", + HttpStatus.OK); + + assertEquals(580000, detail.get("durationMillis").asLong()); + assertIterationStatus(detail, "In review", "WAITING"); + } + + private void step15_whatTheUiReadsNow() throws Exception { + var page = send(get(PIPELINES), null, HttpStatus.OK); + var pipeline = page.get("content").get(0); + assertEquals(90, pipeline.get("settings").get("autoReady").get("threshold").asInt()); + assertEquals(1, pipeline.get("iterationsCount").asInt()); + + var cards = send(get(PIPELINES + "/" + pipelineId + "/iteration"), null, HttpStatus.OK); + var latest = cards.get("content").get(0); + assertEquals(iterationId, latest.get("id").asLong()); + assertIterationStatus(latest, "In review", "WAITING"); + assertEquals(580000, latest.get("durationMillis").asLong()); + assertEquals("2", attribute(latest, "readyNow")); + + var detail = iteration(); + assertIterationStatus(detail, "In review", "WAITING"); + var linkedCreateRow = cellNode(stage(detail, "Create"), 0, 2, "libraryId"); + assertNotNull(linkedCreateRow); + assertEquals(libraryIds.get(2), linkedCreateRow.get("target").get("entityId").asText()); + assertEquals(580000, detail.get("durationMillis").asLong()); + } + + // ------------------------------------------------------------------ the editor finishes the review + + private void editorMarksTheOtherTwoCasesReady_IterationCompletes() throws Exception { + send(patch(TEST_CASES + "/" + libraryEntityIds.get(2)), "{\"status\": \"READY\"}", HttpStatus.OK); + + var afterThird = iteration(); + assertIterationStatus(afterThird, "In review", "WAITING"); + assertEquals("3/4", attribute(stage(afterThird, "Review"), "review")); + assertEquals("1 Draft Test Case waits for review in the Test Case Library", + stage(afterThird, "Review").get("message").asText()); + + send(patch(TEST_CASES + "/" + libraryEntityIds.get(3)), "{\"status\": \"READY\"}", HttpStatus.OK); + + var done = iteration(); + var review = stage(done, "Review"); + assertStage(review, "Done", "PASSED", "REPORTPORTAL", true); + assertEquals("4/4", attribute(review, "review")); + assertNotNull(review.get("finishedAt")); + assertIterationStatus(done, "Completed", "PASSED"); + assertEquals("4", attribute(done, "readyNow")); + + // and back: a case returned to Draft reopens the review + send(patch(TEST_CASES + "/" + libraryEntityIds.get(3)), "{\"status\": \"DRAFT\"}", HttpStatus.OK); + var reopened = iteration(); + assertIterationStatus(reopened, "In review", "WAITING"); + assertEquals("3/4", attribute(stage(reopened, "Review"), "review")); + } + + // ------------------------------------------------------------------ request bodies + + private static String execution(String jobId) { + return """ + {"executorType": "GITLAB_CI", "externalRunId": "%s", "runUrl": "%s%s", "retryable": true, + "retryParameters": {"repo": "epm-rpp/rp-tests", "workflowRef": "main"}} + """.formatted(jobId, RUN_URL, jobId); + } + + /** + * The Create report of step 6 (the table has no Library ids) or of step 13 (the same report, with the Library id of + * every case as a link cell). + */ + private String createStageBody(boolean withLibraryIds) { + var body = mapper.createObjectNode(); + body.put("name", "Create").put("status", "Passed").put("startedAt", "2026-09-19T10:13:00Z") + .put("finishedAt", "2026-09-19T10:19:02Z").put("message", "4 Test Cases authored").put("summary", "4 cases"); + var attributes = body.putArray("attributes"); + attributes.add(attribute("testCases", "Test Cases", "4", "META", "NUMBER").put("order", 1).put("rollup", "SUM")); + attributes.add(attribute("cost", "Cost", "0.94", "NONE", "MONEY").put("direction", "LOWER_BETTER") + .put("rollup", "SUM")); + attributes.add(attribute("model", "Model", "auto (default)", "NONE", "TEXT")); + attributes.add(attribute("note", "Note", "self-review and self-fix included", "META", "TEXT")); + + var sections = body.putArray("sections"); + var cases = sections.addObject().put("key", "authoredCases").put("title", "Authored test cases (child steps)") + .put("role", "RESULT"); + var columns = cases.putArray("columns"); + column(columns, "name", "Child step · authored test case", null); + column(columns, "priority", "Priority", null); + column(columns, "status", "Step status", "STATUS"); + column(columns, "libraryId", "Library id (after Upload)", null); + var rows = cases.putArray("rows"); + for (int i = 0; i < CASES.size(); i++) { + var cells = rows.addObject().put("key", "case-" + (i + 1)).putObject("cells"); + cells.put("name", CASES.get(i).name()).put("priority", CASES.get(i).priority()).put("status", "Passed"); + if (withLibraryIds) { + putLink(cells, "libraryId", libraryIds.get(i)); + } + } + sections.add(tokenUsage(318400, 2100000, 88200, 41200, "0.94")); + body.set("execution", executionNode("8812301")); + return body.toString(); + } + + /** The Grade report of step 8 (no Library ids yet) or of step 13 (with the "test case" link on every row). */ + private String gradeStageBody(boolean withLibraryIds) { + var body = mapper.createObjectNode(); + body.put("name", "Grade").put("status", "Passed").put("affectsIterationStatus", false) + .put("startedAt", "2026-09-19T10:19:10Z").put("finishedAt", "2026-09-19T10:21:41Z") + .put("message", "4 cases graded").put("summary", "79").put("rawOutputSchemaName", "grade"); + var attributes = body.putArray("attributes"); + attributes.add(attribute("suiteScore", "Suite score", "79", "META", "SCORE").put("order", 1) + .put("direction", "HIGHER_BETTER")); + attributes.add(attribute("cost", "Cost", "0.31", "NONE", "MONEY").put("direction", "LOWER_BETTER") + .put("rollup", "SUM")); + attributes.add(attribute("model", "Model", "auto (default)", "NONE", "TEXT")); + + var sections = body.putArray("sections"); + var graded = sections.addObject().put("key", "gradedCases").put("title", "Graded test cases").put("role", "RESULT"); + var columns = graded.putArray("columns"); + column(columns, "testCase", "Test case", null); + column(columns, "name", "Name", null); + for (int i = 0; i < CRITERIA.length; i++) { + column(columns, camel(CRITERIA[i]), CRITERION_LABELS[i] + " /" + CRITERION_MAX[i], "SCORE"); + } + column(columns, "total", "TOTAL /100", "SCORE"); + var rows = graded.putArray("rows"); + var rawOutput = body.putObject("rawOutput"); + rawOutput.put("total_score", 79).putArray("warnings"); + rawOutput.putNull("error"); + var rawOutputCases = rawOutput.putArray("cases"); + for (int i = 0; i < CASES.size(); i++) { + var authored = CASES.get(i); + var row = rows.addObject().put("key", "case-" + (i + 1)); + var cells = row.putObject("cells"); + if (withLibraryIds) { + putLink(cells, "testCase", libraryIds.get(i)); + } + cells.put("name", authored.name()); + var details = row.putArray("details"); + var rawOutputCase = rawOutputCases.addObject().put("name", authored.name()).put("total_score", authored.total()); + var criteria = rawOutputCase.putArray("criteria"); + for (int c = 0; c < CRITERIA.length; c++) { + cells.put(camel(CRITERIA[c]), authored.scores()[c]); + var reasons = criteria.addObject().put("name", CRITERIA[c]).put("score", authored.scores()[c]) + .put("max_score", CRITERION_MAX[c]).putArray("failure_reasons"); + if (authored.reasons()[c] != null) { + reasons.add(authored.reasons()[c]); + var detail = details.addObject(); + detail.put("heading", CRITERION_LABELS[c] + " " + authored.scores()[c] + "/" + CRITERION_MAX[c]); + detail.putArray("items").add(authored.reasons()[c]); + } + } + cells.put("total", authored.total()); + } + sections.add(tokenUsage(96200, 612000, 21400, 12800, "0.31")); + body.set("execution", executionNode("8812317")); + return body.toString(); + } + + /** The Upload report of step 12: the Library id of each case is the {@code target} of the "test case" cell. */ + private String uploadStageBody() { + var body = mapper.createObjectNode(); + body.put("name", "Upload").put("status", "Passed").put("startedAt", "2026-09-19T10:21:50Z") + .put("finishedAt", "2026-09-19T10:22:04Z").put("message", "4 uploaded as Draft").put("summary", "4 ↑"); + var attributes = body.putArray("attributes"); + attributes.add(attribute("uploaded", "uploaded", "4", "META", "NUMBER").put("order", 1)); + attributes.add(attribute("cost", "Cost", "0.02", "NONE", "MONEY").put("direction", "LOWER_BETTER") + .put("rollup", "SUM")); + attributes.add(attribute("model", "Model", "auto (default)", "NONE", "TEXT")); + + var sections = body.putArray("sections"); + var report = sections.addObject().put("key", "uploadReport").put("title", "Upload report").put("role", "RESULT"); + var columns = report.putArray("columns"); + column(columns, "testCase", "Test case", null); + column(columns, "name", "Name", null); + column(columns, "score", "Score", "SCORE"); + column(columns, "result", "Result", null); + var rows = report.putArray("rows"); + for (int i = 0; i < CASES.size(); i++) { + var cells = rows.addObject().put("key", "case-" + (i + 1)).putObject("cells"); + putLink(cells, "testCase", libraryIds.get(i)); + cells.put("name", CASES.get(i).name()).put("score", CASES.get(i).total()).put("result", "Created"); + } + sections.add(tokenUsage(8200, 31000, 0, 900, "0.02")); + body.set("execution", executionNode("8812333")); + return body.toString(); + } + + private ObjectNode executionNode(String jobId) { + try { + return (ObjectNode) mapper.readTree(execution(jobId)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private ObjectNode tokenUsage(int input, int cacheRead, int cacheWrite, int output, String cost) { + var section = mapper.createObjectNode().put("key", "tokenUsage").put("title", "Token usage") + .put("role", "DETAILS"); + var columns = section.putArray("columns"); + column(columns, "model", "Model", null); + column(columns, "input", "Input", "NUMBER"); + column(columns, "cacheRead", "Cache read", "NUMBER"); + column(columns, "cacheWrite", "Cache write", "NUMBER"); + column(columns, "output", "Output", "NUMBER"); + column(columns, "cost", "Est. cost", "MONEY"); + section.putArray("rows").addObject().put("key", "auto").putObject("cells").put("model", "auto (default)") + .put("input", input).put("cacheRead", cacheRead).put("cacheWrite", cacheWrite).put("output", output) + .put("cost", new BigDecimal(cost)); + return section; + } + + private ObjectNode attribute(String key, String label, String value, String type, String valueType) { + return mapper.createObjectNode().put("key", key).put("label", label).put("value", value).put("type", type) + .put("valueType", valueType); + } + + private static void column(ArrayNode columns, String key, String label, String valueType) { + var column = columns.addObject().put("key", key).put("label", label); + if (valueType != null) { + column.put("valueType", valueType); + } + } + + private static void putLink(ObjectNode cells, String column, String libraryId) { + var cell = cells.putObject(column); + cell.put("value", libraryId); + cell.putObject("target").put("entityType", "TEST_CASE").put("entityId", libraryId); + } + + private static String camel(String snake) { + var parts = snake.split("_"); + var result = new StringBuilder(parts[0]); + for (int i = 1; i < parts.length; i++) { + result.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1)); + } + return result.toString(); + } + + // ------------------------------------------------------------------ calls and reading + + private JsonNode reportStage(String stageKey, String body) throws Exception { + return send(put(PIPELINES + "/iteration/" + iterationId + "/stage/" + stageKey), body, HttpStatus.OK); + } + + private JsonNode iteration() throws Exception { + return send(get(PIPELINES + "/iteration/" + iterationId), null, HttpStatus.OK); + } + + private JsonNode libraryCase(int index) throws Exception { + return send(get(TEST_CASES + "/" + libraryEntityIds.get(index)), null, HttpStatus.OK); + } + + private JsonNode send(MockHttpServletRequestBuilder request, String body, HttpStatus expected) throws Exception { + request.with(token(oAuthHelper.getSuperadminToken())); + if (body != null) { + request.contentType(APPLICATION_JSON).content(body); + } + var response = mockMvc.perform(request).andExpect(status().is(expected.value())).andReturn().getResponse(); + var content = response.getContentAsString(); + return content.isBlank() ? mapper.createObjectNode() : mapper.readTree(content); + } + + private static JsonNode stage(JsonNode iteration, String name) { + for (var stage : iteration.path("stages")) { + if (name.equals(stage.path("name").asText())) { + return stage; + } + } + throw new AssertionError("no stage '" + name + "' in " + iteration.path("stages")); + } + + private static JsonNode attributeNode(JsonNode owner, String key) { + for (var attribute : owner.path("attributes")) { + if (key.equals(attribute.path("key").asText())) { + return attribute; + } + } + return null; + } + + private static String attribute(JsonNode owner, String key) { + var attribute = attributeNode(owner, key); + return attribute == null ? null : attribute.path("value").asText(); + } + + private static JsonNode cellNode(JsonNode stage, int section, int row, String column) { + var cell = stage.path("sections").path(section).path("rows").path(row).path("cells").path(column); + return cell.isMissingNode() ? null : cell; + } + + private static String cell(JsonNode stage, int section, int row, String column) { + var cell = cellNode(stage, section, row, column); + return cell == null ? null : cell.path("value").asText(); + } + + private static void assertIterationStatus(JsonNode iteration, String status, String category) { + assertEquals(status, iteration.get("status").asText(), "iteration status"); + assertEquals(category, iteration.get("statusCategory").asText(), "iteration status category"); + } + + private static void assertStage(JsonNode stage, String status, String category, String ownedBy, boolean counts) { + var name = stage.get("name").asText(); + assertEquals(status, stage.get("status").asText(), name + " status"); + assertEquals(category, stage.get("statusCategory").asText(), name + " status category"); + assertEquals(ownedBy, stage.get("ownedBy").asText(), name + " owner"); + assertEquals(counts, stage.get("affectsIterationStatus").asBoolean(), name + " counts towards the iteration"); + } + + private static void assertMoney(String expected, String actual) { + assertNotNull(actual); + assertEquals(0, new BigDecimal(expected).compareTo(new BigDecimal(actual)), + "expected " + expected + " but was " + actual); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineOpenApiIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineOpenApiIntegrationTest.java new file mode 100644 index 0000000000..ce2bcdd843 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineOpenApiIntegrationTest.java @@ -0,0 +1,74 @@ +package com.epam.reportportal.base.core.aifactory.controller.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epam.reportportal.base.ws.BaseMvcTest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Guards the published OpenAPI contract of the pipeline and quality standard endpoints that the UI + * generates its client from. + */ +class PipelineOpenApiIntegrationTest extends BaseMvcTest { + + private static final String LIST_PATH = "/v1/project/{projectKey}/pipeline"; + + private JsonNode api; + + @BeforeEach + void loadApiDocs() throws Exception { + var body = mockMvc.perform(get("/api-docs").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + api = new ObjectMapper().readTree(body); + } + + @Test + void pipelineList_ShouldBeDocumentedAsPageWithOffsetAndLimit() { + var op = api.at("/paths").get(LIST_PATH).get("get"); + + var schema = op.at("/responses/200/content").elements().next().get("schema"); + assertTrue(schema.toString().contains("Page"), "expected a Page schema but was " + schema); + assertTrue(!"array".equals(schema.path("type").asText()), "must not be a bare array: " + schema); + + var paramNames = new java.util.ArrayList(); + op.get("parameters").forEach(p -> paramNames.add(p.get("name").asText())); + assertEquals(1, paramNames.stream().filter("offset"::equals).count(), paramNames.toString()); + assertEquals(1, paramNames.stream().filter("limit"::equals).count(), paramNames.toString()); + } + + @Test + void identityAndStatusFields_ShouldBeRequired() { + assertRequired("PipelineRS", "id", "name", "iterationsCount"); + assertRequired("PipelineIterationSummaryRS", "id", "pipelineId", "iterationNumber", "status", "statusCategory", "stages"); + assertRequired("PipelineIterationDetailRS", "id", "pipelineId", "iterationNumber", "status", "statusCategory", "stages"); + assertRequired("PipelineStageRS", "id", "name", "sequence", "status", "statusCategory", "ownedBy"); + assertRequired("PipelineStageRetryRS", "stageId", "status", "statusCategory"); + assertRequired("TmsQualityStandardRS", "id", "name", "criteria"); + assertRequired("TmsQualityStandardCriterionRS", "id", "name", "maxPoints", "sequence"); + } + + + private void assertRequired(String schemaName, String... fields) { + // schema component names are fully-qualified class names in this API + JsonNode schema = null; + var names = api.at("/components/schemas").fieldNames(); + while (names.hasNext()) { + var name = names.next(); + if (name.matches("com\\.epam\\.reportportal\\.base\\.core\\.[a-z]+\\.dto\\." + schemaName)) { + schema = api.at("/components/schemas").get(name); + } + } + assertTrue(schema != null, "schema not found: " + schemaName); + var actual = new java.util.ArrayList(); + schema.path("required").forEach(n -> actual.add(n.asText())); + assertTrue(actual.containsAll(List.of(fields)), schemaName + " required=" + actual); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategoryTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategoryTest.java new file mode 100644 index 0000000000..5a9eff8e98 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategoryTest.java @@ -0,0 +1,53 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class StatusCategoryTest { + + @ParameterizedTest + @CsvSource({ + "Pending,PENDING", "running,RUNNING", "RUNNING,RUNNING", "Passed,PASSED", "Done,PASSED", "Completed,PASSED", + "Failed,FAILED", "error,FAILED", "Skipped,SKIPPED", "In progress,WAITING", "In review,WAITING", + "NEEDS_HUMAN,WAITING", "needs-human,WAITING", "Queued · waiting for runner,UNKNOWN" + }) + void fromReported_ShouldMapWellKnownTextsAndLeaveTheRestUnknown(String text, StatusCategory expected) { + assertEquals(expected, StatusCategory.fromReported(text)); + } + + @Test + void fromReported_WithNull_ShouldBeUnknown() { + assertEquals(StatusCategory.UNKNOWN, StatusCategory.fromReported(null)); + } + + @Test + void aggregate_ShouldTakeTheWorstOfFailedRunningWaitingPendingPassed() { + assertEquals(StatusCategory.FAILED, StatusCategory.aggregate(Set.of(StatusCategory.PASSED, StatusCategory.FAILED, + StatusCategory.RUNNING))); + assertEquals(StatusCategory.RUNNING, StatusCategory.aggregate(Set.of(StatusCategory.WAITING, + StatusCategory.RUNNING, StatusCategory.PENDING))); + assertEquals(StatusCategory.WAITING, StatusCategory.aggregate(Set.of(StatusCategory.WAITING, + StatusCategory.PENDING, StatusCategory.PASSED))); + assertEquals(StatusCategory.PENDING, StatusCategory.aggregate(Set.of(StatusCategory.PENDING, + StatusCategory.PASSED))); + assertEquals(StatusCategory.PASSED, StatusCategory.aggregate(List.of(StatusCategory.PASSED))); + } + + @Test + void aggregate_WithNothingToJudgeBy_ShouldBePendingPassedOrUnknown() { + assertEquals(StatusCategory.PENDING, StatusCategory.aggregate(List.of())); + assertEquals(StatusCategory.PASSED, StatusCategory.aggregate(List.of(StatusCategory.SKIPPED))); + assertEquals(StatusCategory.UNKNOWN, StatusCategory.aggregate(List.of(StatusCategory.UNKNOWN))); + } + + @Test + void defaultLabel_ShouldBeReadable() { + assertEquals("Running", StatusCategory.RUNNING.defaultLabel()); + assertEquals("Failed", StatusCategory.FAILED.defaultLabel()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapperTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapperTest.java new file mode 100644 index 0000000000..c983d001d9 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapperTest.java @@ -0,0 +1,442 @@ +package com.epam.reportportal.base.core.aifactory.mapper; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.aifactory.PipelineTestData; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRefRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStepResultRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineUserRS; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.core.aifactory.model.PipelineLink; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineRawOutput; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineExecutionData; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineSections; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineSettings; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import com.epam.reportportal.base.infrastructure.persistence.entity.user.User; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PipelineMapperTest { + + private final PipelineMapper mapper = new PipelineMapper(); + + private Pipeline pipeline; + private PipelineIteration iteration; + + @BeforeEach + void setUp() { + var project = new Project(); + project.setId(1L); + + pipeline = new Pipeline(); + pipeline.setId(2L); + pipeline.setProject(project); + pipeline.setName("factory-tc-gen"); + pipeline.setType("TEST_CASE_GENERATION"); + pipeline.setDescription("desc"); + pipeline.setSettings(new PipelineSettings(Map.of("autoReady", Map.of("enabled", true, "threshold", 90)))); + pipeline.setCreatedAt(Instant.parse("2026-01-01T00:00:00Z")); + + iteration = new PipelineIteration(); + iteration.setId(7L); + iteration.setPipeline(pipeline); + iteration.setIterationNumber(3); + iteration.setStatus("Completed"); + iteration.setStatusCategory(StatusCategory.PASSED); + iteration.setRerun(false); + iteration.setStartedAt(Instant.parse("2026-01-01T00:00:00Z")); + iteration.setFinishedAt(Instant.parse("2026-01-01T00:05:00Z")); + iteration.setCreatedBy(10L); + iteration.setCreatedAt(Instant.parse("2026-01-01T00:00:00Z")); + } + + @Test + void toPipelineRS_ShouldMapAllFields() { + pipeline.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("repo").value("org/repo").build()))); + + var result = mapper.toPipelineRS(pipeline, 5L); + + assertEquals(pipeline.getId(), result.getId()); + assertEquals(pipeline.getName(), result.getName()); + assertEquals("TEST_CASE_GENERATION", result.getType()); + assertEquals(pipeline.getDescription(), result.getDescription()); + assertEquals("repo", result.getAttributes().get(0).getKey()); + assertEquals(Map.of("autoReady", Map.of("enabled", true, "threshold", 90)), result.getSettings()); + assertEquals(5L, result.getIterationsCount()); + assertEquals(pipeline.getCreatedAt(), result.getCreatedAt()); + } + + @Test + void toIterationSummaryRS_ShouldMapHeaderFactsAndGivenAttributes() { + var attributes = List.of(PipelineAttribute.builder().key("requirement").value("US-1") + .type(AttributeType.META).build()); + var stages = List.of(PipelineStageSummaryRS.builder().id(50L).build(), + PipelineStageSummaryRS.builder().id(51L).build()); + var startedBy = PipelineUserRS.builder().id(10L).login("jane").build(); + var rerunOf = PipelineIterationRefRS.builder().id(6L).iterationNumber(2).build(); + + var result = mapper.toIterationSummaryRS(iteration, attributes, stages, startedBy, rerunOf); + + assertEquals(7L, result.getId()); + assertEquals(pipeline.getId(), result.getPipelineId()); + assertEquals(pipeline.getName(), result.getPipelineName()); + assertEquals("TEST_CASE_GENERATION", result.getPipelineType()); + assertEquals(3, result.getIterationNumber()); + assertEquals("Completed", result.getStatus()); + assertEquals(StatusCategory.PASSED, result.getStatusCategory()); + assertEquals(attributes, result.getAttributes()); + assertEquals(stages, result.getStages()); + assertEquals(300_000L, result.getDurationMillis()); + assertEquals(startedBy, result.getStartedBy()); + assertEquals(rerunOf, result.getRerunOf()); + } + + @Test + void toIterationSummaryRS_WithoutFinishedAt_ShouldMapNullDuration() { + iteration.setFinishedAt(null); + + var result = mapper.toIterationSummaryRS(iteration, null, List.of(), null, null); + + assertNull(result.getDurationMillis()); + assertNull(result.getAttributes()); + } + + @Test + void toIterationDetailRS_ShouldMapFieldsAndEmbedStages() { + var stages = List.of(com.epam.reportportal.base.core.aifactory.dto.PipelineStageRS.builder().id(50L).build()); + + var result = mapper.toIterationDetailRS(iteration, null, stages, null, null); + + assertEquals(7L, result.getId()); + assertEquals(3, result.getIterationNumber()); + assertEquals(stages, result.getStages()); + assertEquals(300_000L, result.getDurationMillis()); + assertNull(result.getAttributes()); + assertNull(result.getStartedBy()); + } + + @Test + void toUserRS_ShouldMapLoginAndFullName() { + var user = new User(); + user.setId(10L); + user.setLogin("jane"); + user.setFullName("Jane Doe"); + + var result = mapper.toUserRS(user); + + assertEquals(10L, result.getId()); + assertEquals("jane", result.getLogin()); + assertEquals("Jane Doe", result.getFullName()); + } + + @Test + void toStageSummaryRS_ShouldMapStatusAsReportedAndAttributesButNotHeavyDetails() { + var stage = new PipelineStage(); + stage.setId(50L); + stage.setName("Grade"); + stage.setSequence(2); + stage.setParentStageId(40L); + stage.setStatus("Queued · waiting for runner"); + stage.setStatusCategory(StatusCategory.UNKNOWN); + stage.setOwnedBy(StageOwner.PIPELINE); + stage.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("score").value("90").type(AttributeType.META).build()))); + stage.setSections(new PipelineSections(List.of(PipelineSection.builder().key("grades").build()))); + + var result = mapper.toStageSummaryRS(stage); + + assertEquals(50L, result.getId()); + assertEquals("Grade", result.getName()); + assertEquals(2, result.getSequence()); + assertEquals(40L, result.getParentStageId()); + assertEquals("Queued · waiting for runner", result.getStatus()); + assertEquals(StatusCategory.UNKNOWN, result.getStatusCategory()); + assertEquals("score", result.getAttributes().get(0).getKey()); + } + + @Test + void toAttemptRS_ShouldMapAttemptWithDurationLinksResultAndDetails() { + var attempt = new PipelineStageAttempt(); + attempt.setAttempt(1); + attempt.setStatus("Failed"); + attempt.setStatusCategory(StatusCategory.FAILED); + attempt.setStartedAt(Instant.parse("2026-01-01T10:00:00Z")); + attempt.setFinishedAt(Instant.parse("2026-01-01T10:00:05Z")); + attempt.setExecution(new PipelineExecutionData( + PipelineExecution.builder().executorType("GITLAB_CI").externalRunId("job-1").runUrl("https://ci/jobs/1").build())); + attempt.setResultRef("err-17"); + attempt.setAttributes(new PipelineAttributes(List.of(PipelineAttribute.builder().key("cost").value("0.1").build()))); + + var rs = mapper.toAttemptRS(attempt); + + assertEquals(1, rs.getAttempt()); + assertEquals("Failed", rs.getStatus()); + assertEquals(StatusCategory.FAILED, rs.getStatusCategory()); + assertEquals(5_000L, rs.getDurationMillis()); + assertEquals(PipelineLink.RUN, rs.getLinks().get(0).getType()); + assertEquals("https://ci/jobs/1", rs.getLinks().get(0).getUrl()); + assertEquals("err-17", rs.getResult().getResultRef()); + assertEquals("cost", rs.getAttributes().get(0).getKey()); + } + + @Test + void stageTiming_ShouldFlowFromRequestToEntityAndBothResponseShapes() { + var started = Instant.parse("2026-01-01T10:00:00Z"); + var finished = Instant.parse("2026-01-01T10:00:42Z"); + var rq = PipelineStageRQ.builder().stageKey("grade").sequence(2).status("Passed") + .startedAt(started).finishedAt(finished).build(); + + var entity = mapper.toStageEntity(rq); + var summary = mapper.toStageSummaryRS(entity); + var full = mapper.toStageRS(entity); + + assertEquals(started, entity.getStartedAt()); + assertEquals(finished, entity.getFinishedAt()); + assertEquals(42_000L, summary.getDurationMillis()); + assertEquals(started, full.getStartedAt()); + assertEquals(finished, full.getFinishedAt()); + assertEquals(42_000L, full.getDurationMillis()); + } + + @Test + void relatedEntities_ShouldBeCollectedFromTheCellTargetsOfTheSectionsAndReturnedOnTheStage() { + var rq = PipelineStageRQ.builder().stageKey("upload").status("Passed") + .sections(PipelineTestData.sectionLinking(List.of( + new PipelineSection.EntityReference("TEST_CASE", "TC-12"), + new PipelineSection.EntityReference("LAUNCH", "7"), + new PipelineSection.EntityReference("TEST_CASE", "TC-12")))) + .build(); + + var entity = mapper.toStageEntity(rq); + var full = mapper.toStageRS(entity); + + assertEquals(2, entity.getRelatedEntities().getItems().size()); + assertEquals("TEST_CASE", full.getRelatedEntities().get(0).getEntityType()); + assertEquals("TC-12", full.getRelatedEntities().get(0).getEntityId()); + assertEquals("LAUNCH", full.getRelatedEntities().get(1).getEntityType()); + } + + @Test + void relatedEntities_WhenTheReportNoLongerLinksAnything_ShouldClearThem() { + var stage = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("upload").status("Running") + .sections(PipelineTestData.sectionLinking(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-12")))) + .build()); + + mapper.applyStage(stage, PipelineStageRQ.builder().status("Passed").build()); + + assertNull(stage.getRelatedEntities()); + } + + @Test + void stageSummaryAndCountingFlag_ShouldFlowFromRequestToBothResponses() { + var stage = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("grade").status("Passed") + .summary("79").affectsIterationStatus(false).build()); + + assertEquals("79", mapper.toStageRS(stage).getSummary()); + assertEquals("79", mapper.toStageSummaryRS(stage).getSummary()); + assertFalse(mapper.toStageRS(stage).isAffectsIterationStatus()); + assertFalse(mapper.toStageSummaryRS(stage).isAffectsIterationStatus()); + } + + @Test + void stageMessageAndLinks_ShouldFlowFromRequestToResponsesAndKeepTheDerivedRunLink() { + var reported = PipelineLink.builder().type("ENTITY_LIST").label("Open queue") + .target(new PipelineSection.EntityReference("TEST_CASE_LIST", "iteration=7")).build(); + var rq = PipelineStageRQ.builder().stageKey("upload").status("Failed") + .message("Upload failed: folder not found").links(List.of(reported)) + .execution(PipelineExecution.builder().executorType("GITLAB_CI").runUrl("https://ci/run/1").externalRunId("9").build()) + .build(); + + var entity = mapper.toStageEntity(rq); + var summary = mapper.toStageSummaryRS(entity); + var full = mapper.toStageRS(entity); + + assertEquals("Upload failed: folder not found", summary.getMessage()); + assertEquals("Upload failed: folder not found", full.getMessage()); + assertEquals(2, full.getLinks().size()); + assertEquals("ENTITY_LIST", full.getLinks().get(0).getType()); + assertEquals("iteration=7", full.getLinks().get(0).getTarget().getEntityId()); + assertEquals(PipelineLink.RUN, full.getLinks().get(1).getType()); + } + + @Test + void stageTiming_WhenStillRunning_ShouldHaveNoDuration() { + var stage = new PipelineStage(); + stage.setStartedAt(Instant.parse("2026-01-01T10:00:00Z")); + + assertNull(mapper.toStageRS(stage).getDurationMillis()); + } + + @Test + void toStageRS_WithoutCiOrResult_ShouldLeaveThemNull() { + var stage = new PipelineStage(); + stage.setId(50L); + stage.setStageKey("stage-gen-tc"); + stage.setName("Review"); + stage.setSequence(2); + stage.setStatus("In progress"); + stage.setStatusCategory(StatusCategory.WAITING); + stage.setOwnedBy(StageOwner.REPORTPORTAL); + + var result = mapper.toStageRS(stage); + + assertEquals(50L, result.getId()); + assertEquals("In progress", result.getStatus()); + assertEquals(StatusCategory.WAITING, result.getStatusCategory()); + assertEquals(StageOwner.REPORTPORTAL, result.getOwnedBy()); + assertNull(result.getLinks()); + assertNull(result.getResult()); + assertNull(result.getParentStageId()); + assertNull(result.getSections()); + assertNull(result.getRawOutput()); + assertFalse(result.isRetryable()); + } + + @Test + void toStageRS_WithDetails_ShouldMapAttributesSectionsRawOutputResultAndLinks() { + var stage = new PipelineStage(); + stage.setId(50L); + stage.setStageKey("grade"); + stage.setSequence(2); + stage.setParentStageId(40L); + stage.setStatus("Passed"); + stage.setStatusCategory(StatusCategory.PASSED); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITHUB_ACTIONS") + .runUrl("https://ci/runs/1").externalRunId("77").retryable(true).retryParameters(Map.of("repo", "org/repo")).build())); + stage.setAttributes(new PipelineAttributes(List.of(PipelineAttribute.builder().key("agent").value("a@0.4").build()))); + stage.setSections(new PipelineSections(List.of(PipelineSection.builder().key("grades").title("Grades").build()))); + stage.setRawOutput(new PipelineRawOutput(Map.of("total_score", 90))); + stage.setRawOutputSchemaName("grade.schema.json@1"); + stage.setResultType("LAUNCH"); + stage.setResultRef("12"); + + var result = mapper.toStageRS(stage); + + assertEquals(40L, result.getParentStageId()); + assertTrue(result.isRetryable()); + assertEquals("agent", result.getAttributes().get(0).getKey()); + assertEquals("grades", result.getSections().get(0).getKey()); + assertEquals(Map.of("total_score", 90), result.getRawOutput()); + assertEquals("grade.schema.json@1", result.getRawOutputSchemaName()); + assertEquals("LAUNCH", result.getResult().getResultType()); + assertEquals("12", result.getResult().getResultRef()); + assertEquals(PipelineLink.RUN, result.getLinks().get(0).getType()); + assertEquals("#77", result.getLinks().get(0).getLabel()); + } + + @Test + void toStageEntity_WithExecution_ShouldKeepItAsReportedAlongWithTheRest() { + var rq = PipelineStageRQ.builder() + .stageKey("stage-gen-tc") + .name("MD -> Test cases") + .sequence(2) + .status("Passed") + .affectsIterationStatus(false) + .attributes(List.of(PipelineAttribute.builder().key("agent").value("create-test-cases@0.4").build())) + .sections(List.of(PipelineSection.builder().key("rows").build())) + .rawOutput(Map.of("k", "v")) + .rawOutputSchemaName("s@1") + .result(PipelineStepResultRQ.builder().resultType("LAUNCH").resultRef("12").build()) + .execution(PipelineExecution.builder() + .executorType("GITHUB_ACTIONS") + .externalRunId("78911") + .runUrl("https://ci/runs/123456") + .retryable(true) + .retryParameters(Map.of("repo", "org/repo", "workflowRef", "ai-pipeline.yml")) + .build()) + .build(); + + var stage = mapper.toStageEntity(rq); + + assertEquals("stage-gen-tc", stage.getStageKey()); + assertEquals("MD -> Test cases", stage.getName()); + assertEquals(2, stage.getSequence()); + assertEquals("Passed", stage.getStatus()); + assertEquals(StatusCategory.PASSED, stage.getStatusCategory()); + assertEquals(StageOwner.PIPELINE, stage.getOwnedBy()); + assertFalse(stage.isAffectsIterationStatus()); + assertEquals("agent", stage.getAttributes().getItems().get(0).getKey()); + assertEquals("rows", stage.getSections().getItems().get(0).getKey()); + assertEquals(Map.of("k", "v"), stage.getRawOutput().getContent()); + assertEquals("s@1", stage.getRawOutputSchemaName()); + assertEquals("LAUNCH", stage.getResultType()); + assertEquals("12", stage.getResultRef()); + var execution = stage.getExecution().getContent(); + assertEquals("GITHUB_ACTIONS", execution.getExecutorType()); + assertEquals("78911", execution.getExternalRunId()); + assertEquals("https://ci/runs/123456", execution.getRunUrl()); + assertTrue(execution.isRetryable()); + assertEquals(Map.of("repo", "org/repo", "workflowRef", "ai-pipeline.yml"), execution.getRetryParameters()); + } + + @Test + void toStageEntity_WithoutExecutionOrOptionalParts_ShouldLeaveThemUnsetAndCountTowardsIteration() { + var rq = PipelineStageRQ.builder().stageKey("stage-reqs").sequence(1).status("Passed").build(); + + var stage = mapper.toStageEntity(rq); + + assertNull(stage.getExecution()); + assertNull(stage.getAttributes()); + assertNull(stage.getSections()); + assertNull(stage.getRawOutput()); + assertNull(stage.getResultType()); + assertTrue(stage.isAffectsIterationStatus()); + assertEquals("stage-reqs", stage.getName()); + } + + @Test + void toStageEntity_WithExplicitCategory_ShouldKeepItAndWithUnknownTextShouldBeUnknown() { + var explicit = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("a").sequence(1) + .status("Queued · waiting for runner").statusCategory(StatusCategory.RUNNING).build()); + var unknown = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("b").sequence(2) + .status("Queued · waiting for runner").build()); + + assertEquals(StatusCategory.RUNNING, explicit.getStatusCategory()); + assertEquals("Queued · waiting for runner", explicit.getStatus()); + assertEquals(StatusCategory.UNKNOWN, unknown.getStatusCategory()); + } + + @Test + void applyStage_ShouldReplaceWhatWasReportedButKeepNameAndCountingFlagWhenAbsent() { + var stage = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("grade").name("Grade").sequence(2) + .status("Running").affectsIterationStatus(false) + .attributes(List.of(PipelineAttribute.builder().key("old").value("1").build())).build()); + + mapper.applyStage(stage, PipelineStageRQ.builder().status("Passed").build()); + + assertEquals("Grade", stage.getName()); + assertEquals(2, stage.getSequence()); + assertEquals("Passed", stage.getStatus()); + assertEquals(StatusCategory.PASSED, stage.getStatusCategory()); + assertFalse(stage.isAffectsIterationStatus()); + assertNull(stage.getAttributes()); + } + + @Test + void attributeList_WhenEmptyOrNull_ShouldBeNull() { + assertNull(mapper.attributeList(null)); + assertNull(mapper.attributeList(new PipelineAttributes(List.of()))); + assertNull(mapper.sectionList(null)); + assertNull(mapper.sectionList(new PipelineSections(List.of()))); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/model/PipelineSectionTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/model/PipelineSectionTest.java new file mode 100644 index 0000000000..bd573f6c2e --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/model/PipelineSectionTest.java @@ -0,0 +1,59 @@ +package com.epam.reportportal.base.core.aifactory.model; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.aifactory.PipelineTestData; +import com.epam.reportportal.base.core.aifactory.enums.SectionRole; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PipelineSectionTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void entityReferences_ShouldListEachTargetOnceInTheOrderOfAppearance() { + var sections = PipelineTestData.sectionLinking(List.of( + new PipelineSection.EntityReference("TEST_CASE", "TC-2"), + new PipelineSection.EntityReference("TEST_CASE", "TC-1"), + new PipelineSection.EntityReference("TEST_CASE", "TC-2"))); + + var references = PipelineSection.entityReferences(sections); + + assertEquals(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-2"), + new PipelineSection.EntityReference("TEST_CASE", "TC-1")), references); + } + + @Test + void entityReferences_WithoutSectionsOrTargets_ShouldBeEmpty() { + assertTrue(PipelineSection.entityReferences(null).isEmpty()); + var plain = PipelineSection.builder().key("t").rows(List.of(PipelineSection.Row.builder() + .cells(java.util.Map.of("a", PipelineSection.Cell.builder().value("x").build())).build())).build(); + assertTrue(PipelineSection.entityReferences(List.of(plain)).isEmpty()); + } + + @Test + void fromJson_ShouldDefaultTheRoleToResultAndReadTheRowDetails() throws Exception { + var json = """ + {"key": "graded", "columns": [{"key": "name"}], + "rows": [{"key": "r1", "cells": {"name": "Case 1"}, + "details": [{"heading": "Clear steps 12/20", "items": ["Two actions in one step"]}]}]} + """; + + var section = mapper.readValue(json, PipelineSection.class); + + assertEquals(SectionRole.RESULT, section.getRole()); + var details = section.getRows().get(0).getDetails(); + assertEquals("Clear steps 12/20", details.get(0).getHeading()); + assertEquals(List.of("Two actions in one step"), details.get(0).getItems()); + } + + @Test + void fromJson_ShouldReadTheDetailsRole() throws Exception { + var section = mapper.readValue("{\"key\": \"tokenUsage\", \"role\": \"DETAILS\"}", PipelineSection.class); + + assertEquals(SectionRole.DETAILS, section.getRole()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprintTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprintTest.java new file mode 100644 index 0000000000..1edddbb168 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprintTest.java @@ -0,0 +1,45 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.aifactory.dto.IterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class IngestFingerprintTest { + + private PipelineIterationRQ rq(Map rawOutput, String status) { + return PipelineIterationRQ.builder().name("p").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("s").sequence(1).status(status).rawOutput(rawOutput).build())).build()).build(); + } + + @Test + void of_ShouldNotDependOnMapEntryOrder() { + var a = new LinkedHashMap(); + a.put("x", 1); + a.put("y", 2); + var b = new LinkedHashMap(); + b.put("y", 2); + b.put("x", 1); + + assertEquals(IngestFingerprint.of(rq(a, "Passed")), IngestFingerprint.of(rq(b, "Passed"))); + } + + @Test + void of_ShouldDifferWhenBodyDiffers() { + assertNotEquals(IngestFingerprint.of(rq(null, "Passed")), IngestFingerprint.of(rq(null, "Failed"))); + } + + @Test + void sizeInBytes_ShouldGrowWithThePayload() { + var small = IngestFingerprint.sizeInBytes(rq(null, "Passed")); + var large = IngestFingerprint.sizeInBytes(rq(Map.of("blob", "x".repeat(10_000)), "Passed")); + + assertTrue(large > small + 10_000 - 1); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupportTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupportTest.java new file mode 100644 index 0000000000..33089b7e3d --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupportTest.java @@ -0,0 +1,143 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeRollup; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class PipelineAttributeSupportTest { + + private static PipelineAttribute attribute(String key, String value) { + return PipelineAttribute.builder().key(key).value(value).build(); + } + + @Test + void normalize_ShouldApplyDefaultsAndKeepTheLastAttributePerKey() { + var reported = new ArrayList(); + reported.add(PipelineAttribute.builder().key(" env ").value("beta4").type(null).valueType(null).build()); + reported.add(attribute("model", "auto")); + reported.add(attribute("env", "beta5")); + + var result = PipelineAttributeSupport.normalize(reported); + + assertEquals(2, result.size()); + assertEquals("env", result.get(0).getKey()); + assertEquals("beta5", result.get(0).getValue()); + assertEquals(AttributeType.TAG, result.get(0).getType()); + assertEquals(AttributeValueType.TEXT, result.get(0).getValueType()); + } + + @Test + void normalize_WithBlankKey_ShouldBeRejected() { + assertThrows(ReportPortalException.class, + () -> PipelineAttributeSupport.normalize(List.of(attribute(" ", "x")))); + } + + @Test + void normalize_WithTooManyAttributes_ShouldBeRejected() { + var tooMany = new ArrayList(); + for (int i = 0; i <= PipelineAttributeSupport.MAX_ATTRIBUTES; i++) { + tooMany.add(attribute("k" + i, "v")); + } + assertThrows(ReportPortalException.class, () -> PipelineAttributeSupport.normalize(tooMany)); + } + + @Test + void normalize_WithNull_ShouldBeEmpty() { + assertTrue(PipelineAttributeSupport.normalize(null).isEmpty()); + assertNull(PipelineAttributeSupport.toColumn(List.of())); + } + + @Test + void merge_ShouldReplaceByKeyAddNewAndRemoveListedKeys() { + var existing = new PipelineAttributes(List.of(attribute("env", "beta4"), attribute("ci", "#1"), + attribute("jira", "A-1"))); + + var result = PipelineAttributeSupport.merge(existing, List.of(attribute("env", "beta5"), attribute("model", "m")), + List.of("jira")); + + assertEquals(List.of("env", "ci", "model"), result.stream().map(PipelineAttribute::getKey).toList()); + assertEquals("beta5", result.get(0).getValue()); + } + + @Test + void searchText_ShouldJoinLowerCasedValuesAndIgnoreBlanks() { + var text = PipelineAttributeSupport.searchText(List.of(attribute("requirement", "US-TMS-EXP-002"), + attribute("trigger", "Web form"), attribute("empty", " "), attribute("none", null))); + + assertEquals("us-tms-exp-002 web form", text); + assertNull(PipelineAttributeSupport.searchText(List.of())); + } + + @Test + void attributeIndex_ShouldHaveOneTokenPerAttributeMatchingTheFilterToken() { + var index = PipelineAttributeSupport.attributeIndex(List.of(attribute("Env", "Beta5"), attribute("ci", "#1"))); + + assertTrue(index.contains(PipelineAttribute.indexToken("env", "beta5"))); + assertTrue(index.contains(PipelineAttribute.indexToken("CI", "#1"))); + assertTrue(!index.contains(PipelineAttribute.indexToken("env", "beta"))); + } + + @Test + void rollup_ShouldSumFlaggedStageAttributesExceptKeysTheIterationAlreadyHas() { + var create = stage(PipelineAttribute.builder().key("cost").label("Cost").value("0.5") + .valueType(AttributeValueType.MONEY).rollup(AttributeRollup.SUM).build(), + PipelineAttribute.builder().key("tokens").value("100").rollup(AttributeRollup.SUM).build()); + var grade = stage(PipelineAttribute.builder().key("cost").value("0.46") + .valueType(AttributeValueType.MONEY).rollup(AttributeRollup.SUM).build(), + PipelineAttribute.builder().key("note").value("x").build()); + + var result = PipelineAttributeSupport.rollup(List.of(create, grade), Set.of("tokens")); + + assertEquals(1, result.size()); + assertEquals("cost", result.get(0).getKey()); + assertEquals("Cost", result.get(0).getLabel()); + assertEquals("0.96", result.get(0).getValue()); + assertEquals(AttributeType.META, result.get(0).getType()); + assertEquals(AttributeValueType.MONEY, result.get(0).getValueType()); + } + + @Test + void rollup_ShouldKeepThePositionOfTheFirstStageAttributeItSums() { + var create = stage(PipelineAttribute.builder().key("testCases").value("4").order(4) + .rollup(AttributeRollup.SUM).build()); + + var result = PipelineAttributeSupport.rollup(List.of(create), Set.of()); + + assertEquals(4, result.get(0).getOrder()); + } + + @Test + void normalize_ShouldKeepTheDescriptionAndTheComparisonKeyFlag() { + var result = PipelineAttributeSupport.normalize(List.of(PipelineAttribute.builder().key("requirement") + .value("US-1").description("Empty state").comparisonKey(true).build())); + + assertEquals("Empty state", result.get(0).getDescription()); + assertTrue(result.get(0).isComparisonKey()); + } + + @Test + void rollup_ShouldSkipNonNumericValuesAndStagesWithoutAttributes() { + var broken = stage(PipelineAttribute.builder().key("cost").value("n/a").rollup(AttributeRollup.SUM).build()); + + assertTrue(PipelineAttributeSupport.rollup(List.of(broken, new PipelineStage()), Set.of()).isEmpty()); + } + + private static PipelineStage stage(PipelineAttribute... attributes) { + var stage = new PipelineStage(); + stage.setAttributes(new PipelineAttributes(List.of(attributes))); + return stage; + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparatorTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparatorTest.java new file mode 100644 index 0000000000..a84840bffb --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparatorTest.java @@ -0,0 +1,171 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRS; +import com.epam.reportportal.base.core.aifactory.enums.AttributeDirection; +import com.epam.reportportal.base.core.aifactory.enums.AttributeType; +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PipelineComparatorTest { + + private final PipelineComparator comparator = new PipelineComparator(); + + private static PipelineAttribute requirement(String value) { + return PipelineAttribute.builder().key("requirement").value(value).type(AttributeType.META).comparisonKey(true).build(); + } + + private static PipelineAttribute metric(String key, String value, AttributeValueType type, + AttributeDirection direction) { + return PipelineAttribute.builder().key(key).label(key).value(value).type(AttributeType.META).valueType(type) + .direction(direction).build(); + } + + private static PipelineStageRS stage(String name, String status, StatusCategory category, + List sections) { + return PipelineStageRS.builder().name(name).status(status).statusCategory(category).sections(sections).build(); + } + + private static PipelineSection grades(String key, String score) { + return PipelineSection.builder().key("grades").title("Grades") + .columns(List.of(PipelineSection.Column.builder().key("score").build())) + .rows(List.of(PipelineSection.Row.builder().key(key) + .cells(Map.of("score", PipelineSection.Cell.builder().value(score).build())).build())) + .build(); + } + + private static PipelineIterationDetailRS iteration(List attributes, PipelineStageRS... stages) { + return PipelineIterationDetailRS.builder().attributes(attributes).stages(List.of(stages)).build(); + } + + @Test + void compare_ShouldComputeDeltasAndVerdictsFromAttributeDirection() { + var baseline = iteration(List.of(requirement("US-1"), + metric("suiteScore", "79", AttributeValueType.SCORE, AttributeDirection.HIGHER_BETTER), + metric("cost", "1.49", AttributeValueType.MONEY, AttributeDirection.LOWER_BETTER), + metric("cases", "4", AttributeValueType.NUMBER, AttributeDirection.NONE))); + var candidate = iteration(List.of(requirement("US-1"), + metric("suiteScore", "83", AttributeValueType.SCORE, AttributeDirection.HIGHER_BETTER), + metric("cost", "1.62", AttributeValueType.MONEY, AttributeDirection.LOWER_BETTER), + metric("cases", "4", AttributeValueType.NUMBER, AttributeDirection.NONE))); + + var result = comparator.compare(baseline, candidate); + + var byKey = result.getMetrics().stream() + .collect(java.util.stream.Collectors.toMap(PipelineCompareRS.MetricCompare::getKey, m -> m)); + assertEquals("4", byKey.get("suiteScore").getDelta()); + assertEquals(PipelineCompareRS.Verdict.BETTER, byKey.get("suiteScore").getVerdict()); + assertEquals("0.13", byKey.get("cost").getDelta()); + assertEquals(PipelineCompareRS.Verdict.WORSE, byKey.get("cost").getVerdict()); + assertEquals(PipelineCompareRS.Verdict.NOT_APPLICABLE, byKey.get("cases").getVerdict()); + assertEquals("79", byKey.get("suiteScore").getBaseline()); + assertEquals("83", byKey.get("suiteScore").getCandidate()); + } + + @Test + void compare_ShouldListMetricsPresentOnlyOnOneSideWithoutDelta() { + var baseline = iteration(List.of(metric("cost", "1", AttributeValueType.MONEY, AttributeDirection.LOWER_BETTER))); + var candidate = iteration(List.of(metric("suiteScore", "83", AttributeValueType.SCORE, + AttributeDirection.HIGHER_BETTER))); + + var result = comparator.compare(baseline, candidate); + + assertEquals(2, result.getMetrics().size()); + result.getMetrics().forEach(m -> { + assertNull(m.getDelta()); + assertEquals(PipelineCompareRS.Verdict.NOT_APPLICABLE, m.getVerdict()); + }); + } + + @Test + void compare_WithNonNumericAttributes_ShouldNotListThemAsMetrics() { + var attrs = List.of(PipelineAttribute.builder().key("model").value("auto").build()); + + var result = comparator.compare(iteration(attrs), iteration(attrs)); + + assertTrue(result.getMetrics().isEmpty()); + } + + @Test + void verdict_ShouldHandleEqualValuesAndLowerIsBetter() { + assertEquals(PipelineCompareRS.Verdict.SAME, comparator.verdict(BigDecimal.ZERO, AttributeDirection.HIGHER_BETTER)); + assertEquals(PipelineCompareRS.Verdict.BETTER, + comparator.verdict(new BigDecimal("-2"), AttributeDirection.LOWER_BETTER)); + assertEquals(PipelineCompareRS.Verdict.WORSE, + comparator.verdict(new BigDecimal("-2"), AttributeDirection.HIGHER_BETTER)); + assertEquals(PipelineCompareRS.Verdict.NOT_APPLICABLE, comparator.verdict(null, AttributeDirection.HIGHER_BETTER)); + assertEquals(PipelineCompareRS.Verdict.NOT_APPLICABLE, comparator.verdict(BigDecimal.ONE, null)); + } + + @Test + void compare_ShouldMatchStagesByNameAndKeepOneSidedOnes() { + var baseline = iteration(List.of(), stage("Create", "Passed", StatusCategory.PASSED, null), + stage("Removed", "Failed", StatusCategory.FAILED, null)); + var candidate = iteration(List.of(), stage("Create", "Failed", StatusCategory.FAILED, null), + stage("New", "Passed", StatusCategory.PASSED, null)); + + var result = comparator.compare(baseline, candidate); + + assertEquals(List.of("Create", "New", "Removed"), + result.getStages().stream().map(PipelineCompareRS.StageCompare::getName).toList()); + var create = result.getStages().get(0); + assertEquals("Passed", create.getBaseline().getStatus()); + assertEquals("Failed", create.getCandidate().getStatus()); + assertNull(result.getStages().get(1).getBaseline()); + assertNull(result.getStages().get(2).getCandidate()); + } + + @Test + void compare_WithEqualIdentity_ShouldBeSameScopeAndJoinResultRowsByKey() { + var baseline = iteration(List.of(requirement("US-1")), + stage("Grade", "Passed", StatusCategory.PASSED, List.of(grades("TC-1", "70")))); + var candidate = iteration(List.of(requirement("US-1")), + stage("Grade", "Passed", StatusCategory.PASSED, List.of(grades("TC-1", "90")))); + + var result = comparator.compare(baseline, candidate); + + assertTrue(result.isSameScope()); + assertNull(result.getNote()); + assertEquals(1, result.getSections().size()); + var section = result.getSections().get(0); + assertEquals("Grade", section.getStageName()); + assertEquals("grades", section.getSectionKey()); + var row = section.getRows().get(0); + assertEquals("TC-1", row.getKey()); + assertEquals("70", row.getBaseline().getCells().get("score").getValue()); + assertEquals("90", row.getCandidate().getCells().get("score").getValue()); + } + + @Test + void compare_WithDifferentIdentity_ShouldNoteItAndSkipRows() { + var baseline = iteration(List.of(requirement("US-1")), + stage("Grade", "Passed", StatusCategory.PASSED, List.of(grades("TC-1", "70")))); + var candidate = iteration(List.of(requirement("US-2")), + stage("Grade", "Passed", StatusCategory.PASSED, List.of(grades("TC-1", "90")))); + + var result = comparator.compare(baseline, candidate); + + assertFalse(result.isSameScope()); + assertEquals(PipelineComparator.DIFFERENT_SCOPE_NOTE, result.getNote()); + assertTrue(result.getSections().isEmpty()); + } + + @Test + void compare_WithoutIdentityAttributes_ShouldNotBeSameScope() { + var result = comparator.compare(iteration(List.of()), iteration(List.of())); + + assertFalse(result.isSameScope()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresherTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresherTest.java new file mode 100644 index 0000000000..8261bdd160 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresherTest.java @@ -0,0 +1,203 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.mockito.Mockito; + +class PipelineIterationRefresherTest { + + private final PipelineIterationRepository iterationRepository = Mockito.mock(PipelineIterationRepository.class); + private final PipelineStageRepository stageRepository = Mockito.mock(PipelineStageRepository.class); + private final PipelineStageContributor contributor = Mockito.mock(PipelineStageContributor.class); + + private PipelineIterationRefresher refresher; + private PipelineIteration iteration; + + @BeforeEach + void setUp() { + refresher = new PipelineIterationRefresher(iterationRepository, stageRepository, + List.of(contributor)); + var pipeline = new Pipeline(); + pipeline.setType("T"); + iteration = new PipelineIteration(); + iteration.setId(7L); + iteration.setPipeline(pipeline); + when(iterationRepository.save(any(PipelineIteration.class))).thenAnswer(i -> i.getArgument(0)); + when(stageRepository.save(any(PipelineStage.class))).thenAnswer(i -> { + var stage = (PipelineStage) i.getArgument(0); + if (stage.getId() == null) { + stage.setId(99L); + } + return stage; + }); + } + + private static PipelineStage stage(long id, StatusCategory category, boolean counts) { + var stage = new PipelineStage(); + stage.setId(id); + stage.setStatus(category.defaultLabel()); + stage.setStatusCategory(category); + stage.setAffectsIterationStatus(counts); + return stage; + } + + private void givenStages(PipelineStage... stages) { + when(stageRepository.findByIterationIdOrderBySequenceAsc(7L)).thenReturn(List.of(stages)); + when(contributor.supports(any())).thenReturn(false); + } + + @Test + void refresh_WhenAStageThatDoesNotCountIsRunning_ShouldStillShowTheIterationAsRunning() { + givenStages(stage(1, StatusCategory.PASSED, true), stage(2, StatusCategory.RUNNING, false), + stage(3, StatusCategory.PENDING, true)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.RUNNING, iteration.getStatusCategory()); + } + + @Test + void refresh_BetweenTwoStages_ShouldBeRunningNotPending() { + givenStages(stage(1, StatusCategory.PASSED, true), stage(2, StatusCategory.PENDING, false), + stage(3, StatusCategory.PENDING, true)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.RUNNING, iteration.getStatusCategory()); + assertEquals("Running", iteration.getStatus()); + } + + @Test + void refresh_WhenNothingHasStartedYet_ShouldBePending() { + givenStages(stage(1, StatusCategory.PENDING, true), stage(2, StatusCategory.PENDING, true)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.PENDING, iteration.getStatusCategory()); + } + + @Test + void refresh_WhenAStageThatDoesNotCountFails_ShouldNotFailTheIteration() { + givenStages(stage(1, StatusCategory.PASSED, true), stage(2, StatusCategory.FAILED, false), + stage(3, StatusCategory.PASSED, true)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.PASSED, iteration.getStatusCategory()); + } + + @Test + void refresh_ShouldDeriveWorstCategoryOfCountingStagesAndDefaultLabel() { + givenStages(stage(1, StatusCategory.PASSED, true), stage(2, StatusCategory.FAILED, true), + stage(3, StatusCategory.RUNNING, true)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.FAILED, iteration.getStatusCategory()); + assertEquals("Failed", iteration.getStatus()); + } + + @Test + void refresh_ShouldIgnoreStagesThatDoNotAffectTheIterationStatus() { + givenStages(stage(1, StatusCategory.PASSED, true), stage(2, StatusCategory.FAILED, false)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.PASSED, iteration.getStatusCategory()); + } + + @Test + void refresh_WithAnUnknownStatusAndNothingElseCounting_ShouldBeUnknownAndOtherwiseIgnoreIt() { + givenStages(stage(1, StatusCategory.UNKNOWN, true)); + refresher.refresh(iteration); + assertEquals(StatusCategory.UNKNOWN, iteration.getStatusCategory()); + + givenStages(stage(1, StatusCategory.UNKNOWN, true), stage(2, StatusCategory.RUNNING, true)); + refresher.refresh(iteration); + assertEquals(StatusCategory.RUNNING, iteration.getStatusCategory()); + } + + @Test + void refresh_WithoutStages_ShouldBePending() { + givenStages(); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.PENDING, iteration.getStatusCategory()); + assertEquals("Pending", iteration.getStatus()); + } + + @Test + void refresh_WithOnlyNonCountingStages_ShouldBePending() { + givenStages(stage(1, StatusCategory.PENDING, false)); + + refresher.refresh(iteration); + + assertEquals(StatusCategory.PENDING, iteration.getStatusCategory()); + } + + @Test + void refresh_ShouldSaveContributedStagesGiveNewOnesTheirOwnIdAsPathAndUseTheContributorsLabel() { + var passed = stage(1, StatusCategory.PASSED, true); + when(stageRepository.findByIterationIdOrderBySequenceAsc(7L)).thenReturn(List.of(passed)); + when(contributor.supports(any())).thenReturn(true); + var contributed = new PipelineStage(); + contributed.setStatus("In progress"); + contributed.setStatusCategory(StatusCategory.WAITING); + contributed.setAffectsIterationStatus(true); + when(contributor.contribute(any(), any())).thenReturn(List.of(contributed)); + when(contributor.iterationStatusLabel(StatusCategory.WAITING)).thenReturn(Optional.of("In review")); + + refresher.refresh(iteration); + + assertEquals("99", contributed.getPath()); + assertEquals(StatusCategory.WAITING, iteration.getStatusCategory()); + assertEquals("In review", iteration.getStatus()); + } + + @Test + void refresh_ShouldAddIterationAttributesContributedByTheStages() { + when(stageRepository.findByIterationIdOrderBySequenceAsc(7L)) + .thenReturn(List.of(stage(1, StatusCategory.PASSED, true))); + when(contributor.supports(any())).thenReturn(true); + when(contributor.contribute(any(), any())).thenReturn(List.of()); + when(contributor.iterationAttributes(any(), any())).thenReturn(List.of( + com.epam.reportportal.base.core.aifactory.model.PipelineAttribute.builder().key("readyNow") + .label("Ready now").value("2") + .type(com.epam.reportportal.base.core.aifactory.enums.AttributeType.META) + .valueType(com.epam.reportportal.base.core.aifactory.enums.AttributeValueType.NUMBER).build())); + + refresher.refresh(iteration); + + assertEquals("readyNow", iteration.getAttributes().getItems().get(0).getKey()); + assertEquals("2", iteration.getAttributes().getItems().get(0).getValue()); + assertEquals("2", iteration.getSearchText()); + } + + @Test + void refreshIterations_ShouldRefreshEachIterationAndKeepGoingWhenOneFails() { + when(iterationRepository.findByIdForUpdate(1L)).thenThrow(new IllegalStateException("boom")); + when(iterationRepository.findByIdForUpdate(7L)).thenReturn(Optional.of(iteration)); + givenStages(stage(1, StatusCategory.PASSED, true)); + + refresher.refreshIterations(List.of(1L, 7L)); + + verify(iterationRepository).save(iteration); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImplTest.java new file mode 100644 index 0000000000..81698bf07a --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImplTest.java @@ -0,0 +1,837 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.PipelineTestData; +import com.epam.reportportal.base.core.aifactory.dto.IterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineCompareRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationDetailRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationPatchRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationSummaryRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; +import com.epam.reportportal.base.core.aifactory.dto.PipelineWarningRS; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.mapper.PipelineMapper; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.core.aifactory.model.PipelineSection; +import com.epam.reportportal.base.infrastructure.persistence.dao.ProjectRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageAttemptRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineAttributes; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineExecutionData; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.epam.reportportal.base.util.OffsetRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PipelineIterationServiceImplTest { + + @Mock + private PipelineRepository pipelineRepository; + @Mock + private PipelineIterationRepository pipelineIterationRepository; + @Mock + private PipelineStageRepository pipelineStageRepository; + @Mock + private PipelineStageAttemptRepository pipelineStageAttemptRepository; + @Mock + private PipelineRelatedEntityResolver testCaseResolver; + @Mock + private PipelineStageReportedHandler stageHandler; + @Spy + private List stageReportedHandlers = new ArrayList<>(); + @Spy + private List relatedEntityResolvers = new ArrayList<>(); + @Mock + private ProjectRepository projectRepository; + @Spy + private PipelineMapper pipelineMapper = new PipelineMapper(); + @Mock + private PipelineViewAssembler viewAssembler; + @Mock + private PipelineIterationRefresher refresher; + @Mock + private PipelineComparator comparator; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private PipelineRawOutputValidator rawOutputValidator; + + private PipelineIterationServiceImpl sut; + + private Long projectId; + private Long userId; + private Pipeline pipeline; + private Project project; + private PipelineIterationDetailRS renderedDetail; + private final AtomicLong ids = new AtomicLong(500); + + @BeforeEach + void setUp() { + projectId = 1L; + userId = 10L; + + // built by hand: the two lists have the same type, so field injection could not tell them apart + sut = new PipelineIterationServiceImpl(pipelineRepository, pipelineIterationRepository, + pipelineStageRepository, pipelineStageAttemptRepository, relatedEntityResolvers, stageReportedHandlers, + projectRepository, pipelineMapper, viewAssembler, refresher, comparator, eventPublisher, + rawOutputValidator); + + project = new Project(); + project.setId(projectId); + + pipeline = new Pipeline(); + pipeline.setId(2L); + pipeline.setProject(project); + pipeline.setName("factory-tc-gen"); + + when(testCaseResolver.entityType()).thenReturn("TEST_CASE"); + when(testCaseResolver.resolve(any(), any(), any(), any())) + .thenReturn(List.of()); + relatedEntityResolvers.add(testCaseResolver); + when(stageHandler.onStageReported(any())).thenReturn(List.of()); + stageReportedHandlers.add(stageHandler); + + renderedDetail = PipelineIterationDetailRS.builder().build(); + when(pipelineRepository.findByProjectIdAndName(projectId, "factory-tc-gen")).thenReturn(Optional.of(pipeline)); + when(pipelineRepository.findByIdForUpdate(pipeline.getId())).thenReturn(Optional.of(pipeline)); + when(pipelineRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipeline.getId())) + .thenReturn(Optional.empty()); + when(pipelineIterationRepository.save(any())).thenAnswer(inv -> { + var iteration = (PipelineIteration) inv.getArgument(0); + if (iteration.getId() == null) { + iteration.setId(ids.incrementAndGet()); + } + return iteration; + }); + when(pipelineStageRepository.saveAll(any())).thenAnswer(inv -> { + List stages = new ArrayList<>(inv.getArgument(0)); + stages.stream().filter(stage -> stage.getId() == null).forEach(stage -> stage.setId(ids.incrementAndGet())); + return stages; + }); + when(pipelineStageRepository.save(any())).thenAnswer(inv -> { + var stage = (PipelineStage) inv.getArgument(0); + if (stage.getId() == null) { + stage.setId(ids.incrementAndGet()); + } + return stage; + }); + when(refresher.refresh(any())).thenAnswer(inv -> inv.getArgument(0)); + when(viewAssembler.detail(any())).thenReturn(renderedDetail); + } + + private static PipelineStageRQ stageRQ(String stageKey, String status, List refs) { + return PipelineStageRQ.builder().stageKey(stageKey).status(status) + .sections(refs == null ? null : PipelineTestData.sectionLinking(refs)).build(); + } + + private PipelineIteration persistedIteration(long id) { + var iteration = new PipelineIteration(); + iteration.setId(id); + iteration.setPipeline(pipeline); + iteration.setIterationNumber(2); + iteration.setStatus("Running"); + iteration.setStatusCategory(StatusCategory.RUNNING); + when(pipelineIterationRepository.findById(id)).thenReturn(Optional.of(iteration)); + when(pipelineIterationRepository.findByIdForUpdate(id)).thenReturn(Optional.of(iteration)); + return iteration; + } + + private PipelineStage persistedStage(long iterationId, String key, String status, StatusCategory category) { + var stage = new PipelineStage(); + stage.setId(ids.incrementAndGet()); + stage.setStageKey(key); + stage.setName(key); + stage.setSequence(1); + stage.setAttempt(1); + stage.setStatus(status); + stage.setStatusCategory(category); + stage.setOwnedBy(StageOwner.PIPELINE); + pipelineIterationRepository.findById(iterationId).ifPresent(stage::setIteration); + when(pipelineStageRepository.findByIterationIdAndStageKey(iterationId, key)).thenReturn(Optional.of(stage)); + return stage; + } + + // ------------------------------------------------------------------ create + + @Test + void create_NewPipelineFirstIteration_ShouldCreatePipelineWithTypeAndNumberOne() { + when(pipelineRepository.findByProjectIdAndName(projectId, "new-pipeline")).thenReturn(Optional.empty()); + when(projectRepository.getReferenceById(projectId)).thenReturn(project); + var created = new Pipeline(); + created.setId(3L); + created.setProject(project); + created.setName("new-pipeline"); + when(pipelineRepository.save(any())).thenAnswer(inv -> { + var saved = (Pipeline) inv.getArgument(0); + saved.setId(3L); + return saved; + }); + when(pipelineRepository.findByIdForUpdate(3L)).thenAnswer(inv -> Optional.of(created)); + when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(3L)) + .thenReturn(Optional.empty()); + var rq = PipelineIterationRQ.builder().name("new-pipeline").type("TEST_CASE_GENERATION").iteration(IterationRQ.builder().stages(List.of(stageRQ("create", "Passed", null))).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + var pipelineCaptor = ArgumentCaptor.forClass(Pipeline.class); + verify(pipelineRepository, org.mockito.Mockito.atLeastOnce()).save(pipelineCaptor.capture()); + assertTrue(pipelineCaptor.getAllValues().stream().anyMatch(p -> "new-pipeline".equals(p.getName()))); + var iterationCaptor = ArgumentCaptor.forClass(PipelineIteration.class); + verify(pipelineIterationRepository, org.mockito.Mockito.atLeastOnce()).save(iterationCaptor.capture()); + assertEquals(1, iterationCaptor.getAllValues().get(0).getIterationNumber()); + assertEquals(userId, iterationCaptor.getAllValues().get(0).getCreatedBy()); + } + + @Test + void create_ExistingPipelineWithPriorIterations_ShouldIncrementIterationNumber() { + var previous = new PipelineIteration(); + previous.setIterationNumber(3); + when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipeline.getId())) + .thenReturn(Optional.of(previous)); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + var captor = ArgumentCaptor.forClass(PipelineIteration.class); + verify(pipelineIterationRepository).save(captor.capture()); + assertEquals(4, captor.getValue().getIterationNumber()); + verify(projectRepository, never()).getReferenceById(any()); + } + + @Test + void create_ShouldRecordPipelineTypeTheFirstTimeAndMergePipelineAttributes() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").type("TEST_CASE_GENERATION").attributes(List.of(PipelineAttribute.builder().key("repo").value("org/repo").build())).iteration(IterationRQ.builder().build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + assertEquals("TEST_CASE_GENERATION", pipeline.getType()); + assertEquals("repo", pipeline.getAttributes().getItems().get(0).getKey()); + } + + @Test + void create_WithADifferentTypeThanRecorded_ShouldBeRejected() { + pipeline.setType("TEST_AUTOMATION"); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").type("TEST_CASE_GENERATION").iteration(IterationRQ.builder().build()).build(); + + var ex = assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + + assertTrue(ex.getMessage().contains("TEST_AUTOMATION")); + verify(pipelineIterationRepository, never()).save(any()); + } + + @Test + void create_WithoutStages_ShouldBeAllowedAndRefreshTheDerivedStatus() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); + + var result = sut.createPipelineIteration(projectId, userId, rq, null); + + assertFalse(result.replayed()); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).saveAll(List.of()); + verify(refresher).refresh(any(PipelineIteration.class)); + } + + @Test + void create_WithAttributes_ShouldStoreNormalizedAttributesSearchTextAndIndex() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().attributes(List.of( + PipelineAttribute.builder().key("requirement").value("US-TMS-EXP-002").build(), + PipelineAttribute.builder().key("env").value("beta5").build())).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + var captor = ArgumentCaptor.forClass(PipelineIteration.class); + verify(pipelineIterationRepository).save(captor.capture()); + var iteration = captor.getValue(); + assertEquals(2, iteration.getAttributes().getItems().size()); + assertEquals("us-tms-exp-002 beta5", iteration.getSearchText()); + assertTrue(iteration.getAttributeIndex().contains(PipelineAttribute.indexToken("env", "beta5"))); + } + + @Test + void create_WithoutAttributes_ShouldLeaveThemNull() { + sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build()); + + var captor = ArgumentCaptor.forClass(PipelineIteration.class); + verify(pipelineIterationRepository).save(captor.capture()); + assertNull(captor.getValue().getAttributes()); + assertNull(captor.getValue().getSearchText()); + } + + @Test + void create_WithStages_ShouldSaveThemWithAttemptOneAndSequenceFromPositionWhenAbsent() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("create", "Passed", null), stageRQ("grade", "Running", null))).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).saveAll(captor.capture()); + var stages = captor.getAllValues().get(0); + assertEquals(List.of(1, 2), stages.stream().map(PipelineStage::getSequence).toList()); + assertEquals(List.of(1, 1), stages.stream().map(PipelineStage::getAttempt).toList()); + assertEquals(StatusCategory.PASSED, stages.get(0).getStatusCategory()); + assertEquals(StatusCategory.RUNNING, stages.get(1).getStatusCategory()); + } + + @Test + void create_WithNestedStage_ShouldAssignParentStageIdAndPath() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of( + PipelineStageRQ.builder().stageKey("parent").status("Passed").build(), + PipelineStageRQ.builder().stageKey("child").status("Passed").parentStageKey("parent").build())).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).saveAll(captor.capture()); + var stages = captor.getAllValues().get(0); + var parent = stages.get(0); + var child = stages.get(1); + assertEquals(String.valueOf(parent.getId()), parent.getPath()); + assertNull(parent.getParentStageId()); + assertEquals(parent.getId() + "." + child.getId(), child.getPath()); + assertEquals(parent.getId(), child.getParentStageId()); + } + + @Test + void create_WithUnknownParentStageKey_ShouldThrowBadRequest() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("child").status("Passed") + .parentStageKey("missing-parent").build())).build()).build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + } + + @Test + void create_WithCycleInParentChain_ShouldThrowBadRequest() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of( + PipelineStageRQ.builder().stageKey("a").status("Passed").parentStageKey("b").build(), + PipelineStageRQ.builder().stageKey("b").status("Passed").parentStageKey("a").build())).build()).build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + } + + @Test + void create_WithDuplicateOrBlankStageKeys_ShouldThrowBadRequest() { + var duplicate = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("a", "Passed", null), stageRQ("a", "Passed", null))).build()).build(); + var blank = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ(" ", "Passed", null))).build()).build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, duplicate)); + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, blank)); + } + + @Test + void create_WithOversizedReport_ShouldBeRejected() { + var rawOutput = Map.of("blob", "x".repeat((int) PipelineIterationServiceImpl.MAX_REPORT_BYTES)); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("grade").status("Passed").rawOutput(rawOutput).build())).build()).build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + verify(pipelineRepository, never()).findByProjectIdAndName(any(), any()); + } + + @Test + void create_RerunOfAnIterationOfTheSamePipeline_ShouldCreateNewIterationLinkedToTheSource() { + var source = persistedIteration(99L); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().rerunOfIterationId(99L).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + var captor = ArgumentCaptor.forClass(PipelineIteration.class); + verify(pipelineIterationRepository).save(captor.capture()); + assertTrue(captor.getValue().isRerun()); + assertEquals(source.getId(), captor.getValue().getRerunOfIterationId()); + org.junit.jupiter.api.Assertions.assertNotEquals(source.getId(), captor.getValue().getId()); + } + + @Test + void create_RerunOfAnIterationOfAnotherPipeline_ShouldThrowNotFound() { + var other = new Pipeline(); + other.setId(77L); + var source = new PipelineIteration(); + source.setId(99L); + source.setPipeline(other); + when(pipelineIterationRepository.findById(99L)).thenReturn(Optional.of(source)); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().rerunOfIterationId(99L).build()).build(); + + var ex = assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + + assertTrue(ex.getMessage().contains("99")); + verify(pipelineIterationRepository, never()).save(any()); + } + + @Test + void create_ShouldPublishIngestedEventAfterRefreshing() { + sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build()); + + verify(eventPublisher).publishEvent(any(com.epam.reportportal.base.core.aifactory.event.PipelineIterationIngestedEvent.class)); + } + + // ------------------------------------------------------------------ idempotency + + @Test + void create_WithBlankOrTooLongIdempotencyKey_ShouldThrowBadRequest() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq, " ")); + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq, + "k".repeat(PipelineIterationServiceImpl.MAX_IDEMPOTENCY_KEY_LENGTH + 1))); + } + + @Test + void create_ReplayOfTheSameKeyAndBody_ShouldAnswerWithTheFirstIterationAndCreateNothing() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); + var previous = new PipelineIteration(); + previous.setId(321L); + previous.setIdempotencyHash(IngestFingerprint.of(rq)); + when(pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(pipeline.getId(), "key-1")) + .thenReturn(Optional.of(previous)); + + var result = sut.createPipelineIteration(projectId, userId, rq, "key-1"); + + assertTrue(result.replayed()); + assertSame(renderedDetail, result.iteration()); + verify(pipelineIterationRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void create_SameKeyWithDifferentBody_ShouldThrowIdempotencyConflict() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); + var previous = new PipelineIteration(); + previous.setIdempotencyHash("another-hash"); + when(pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(pipeline.getId(), "key-1")) + .thenReturn(Optional.of(previous)); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.createPipelineIteration(projectId, userId, rq, "key-1")); + + assertEquals(ErrorType.IDEMPOTENCY_KEY_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + + @Test + void create_WithNewKey_ShouldStoreKeyAndFingerprintOnTheIteration() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); + when(pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(pipeline.getId(), "key-1")) + .thenReturn(Optional.empty()); + + sut.createPipelineIteration(projectId, userId, rq, "key-1"); + + var captor = ArgumentCaptor.forClass(PipelineIteration.class); + verify(pipelineIterationRepository).save(captor.capture()); + assertEquals("key-1", captor.getValue().getIdempotencyKey()); + assertEquals(IngestFingerprint.of(rq), captor.getValue().getIdempotencyHash()); + } + + // ------------------------------------------------------------------ refs + + private static List relatedTestCases(String... displayIds) { + return java.util.Arrays.stream(displayIds) + .map(id -> new PipelineSection.EntityReference("TEST_CASE", id)).toList(); + } + + @Test + void create_WithStageRefs_ShouldHandThemToTheResolverAndWarnAboutTheUnresolvedOnes() { + when(testCaseResolver.resolve(eq(projectId), any(), any(), eq(List.of("TC-1", "TC-404")))) + .thenReturn(List.of("TC-404")); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("upload", "Passed", relatedTestCases("TC-1", "TC-404")))).build()).build(); + var detail = PipelineIterationDetailRS.builder().build(); + when(viewAssembler.detail(any())).thenReturn(detail); + + var result = sut.createPipelineIteration(projectId, userId, rq); + + var warnings = result.getWarnings(); + assertEquals(1, warnings.size()); + assertEquals(PipelineWarningRS.RELATED_ENTITY_NOT_FOUND, warnings.get(0).getCode()); + assertEquals("TEST_CASE", warnings.get(0).getEntityType()); + assertEquals(List.of("TC-404"), warnings.get(0).getEntityIds()); + } + + @Test + void create_ShouldTellTheHandlersWhetherEachStagePassed() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("create", "Passed", null), stageRQ("upload", "Running", null))).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + var reports = ArgumentCaptor.forClass(PipelineStageReportedHandler.StageReport.class); + verify(stageHandler, org.mockito.Mockito.times(2)).onStageReported(reports.capture()); + assertEquals(true, reports.getAllValues().get(0).stagePassed()); + assertEquals(false, reports.getAllValues().get(1).stagePassed()); + assertSame(pipeline, reports.getAllValues().get(0).pipeline()); + } + + @Test + void create_WithAHandlerAnswer_ShouldAddItsAttributesToTheStage() { + var promoted = PipelineAttribute.builder().key("autoReadyPromoted").value("2").build(); + when(stageHandler.onStageReported(any())).thenReturn(List.of(promoted)); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("upload", "Passed", null))).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + var saved = ArgumentCaptor.forClass(PipelineStage.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); + assertEquals("2", saved.getValue().getAttributes().getItems().stream() + .filter(attribute -> "autoReadyPromoted".equals(attribute.getKey())).findFirst().orElseThrow().getValue()); + } + + @Test + void create_WithRelatedEntitiesOfATypeNothingHandles_ShouldKeepThemWithoutWarning() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("upload", "Passed", List.of(new PipelineSection.EntityReference("LAUNCH", "12"))))).build()).build(); + var detail = PipelineIterationDetailRS.builder().build(); + when(viewAssembler.detail(any())).thenReturn(detail); + + var result = sut.createPipelineIteration(projectId, userId, rq); + + assertNull(result.getWarnings()); + var saved = ArgumentCaptor.forClass(List.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).saveAll(saved.capture()); + var stage = (PipelineStage) saved.getAllValues().get(0).get(0); + assertEquals("LAUNCH", stage.getRelatedEntities().getItems().get(0).getEntityType()); + assertEquals("12", stage.getRelatedEntities().getItems().get(0).getEntityId()); + } + + @Test + void create_WithoutRefs_ShouldNotCallTheResolvers() { + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(stageRQ("upload", "Passed", null))).build()).build(); + + sut.createPipelineIteration(projectId, userId, rq); + + verify(testCaseResolver, never()).resolve(any(), any(), any(), any()); + } + + // ------------------------------------------------------------------ report stage + + @Test + void reportStage_ForANewStage_ShouldCreateItAfterTheExistingOnesWithRootPathAndRefresh() { + var iteration = persistedIteration(10L); + var create = new PipelineStage(); + create.setSequence(4); + when(pipelineStageRepository.findByIterationIdOrderBySequenceAsc(10L)).thenReturn(List.of(create)); + + var result = sut.reportStage(projectId, 10L, "grade", stageRQ(null, "Queued · waiting for runner", null)); + + var captor = ArgumentCaptor.forClass(PipelineStage.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + var stage = captor.getValue(); + assertEquals("grade", stage.getStageKey()); + assertEquals(5, stage.getSequence()); + assertEquals(1, stage.getAttempt()); + assertEquals(String.valueOf(stage.getId()), stage.getPath()); + assertEquals(StatusCategory.UNKNOWN, stage.getStatusCategory()); + assertSame(iteration, stage.getIteration()); + verify(refresher).refresh(iteration); + assertSame(renderedDetail, result); + } + + @Test + void reportStage_WithARawOutputThatFailsItsSchema_ShouldFailTheStageAndNotKeepIt() { + persistedIteration(10L); + when(pipelineStageRepository.findByIterationIdOrderBySequenceAsc(10L)).thenReturn(List.of()); + when(rawOutputValidator.validate(org.mockito.ArgumentMatchers.eq("grade"), any())) + .thenReturn(Optional.of("total_score is missing")); + var rq = PipelineStageRQ.builder().status("Passed").rawOutputSchemaName("grade") + .rawOutput(java.util.Map.of("warnings", List.of())).build(); + + sut.reportStage(projectId, 10L, "grade", rq); + + var captor = ArgumentCaptor.forClass(PipelineStage.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + var stage = captor.getValue(); + assertEquals(StatusCategory.FAILED, stage.getStatusCategory()); + assertEquals("Failed", stage.getStatus()); + org.junit.jupiter.api.Assertions.assertNull(stage.getRawOutput()); + assertEquals("total_score is missing", stage.getAttributes().getItems().stream() + .filter(attribute -> "failureReason".equals(attribute.getKey())) + .findFirst().orElseThrow().getValue()); + } + + @Test + void reportStage_ForANewNestedStage_ShouldHangItUnderTheParentPath() { + persistedIteration(10L); + var parent = persistedStage(10L, "parent", "Running", StatusCategory.RUNNING); + parent.setPath("42"); + + sut.reportStage(projectId, 10L, "child", + PipelineStageRQ.builder().status("Running").parentStageKey("parent").build()); + + var captor = ArgumentCaptor.forClass(PipelineStage.class); + verify(pipelineStageRepository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + var child = captor.getValue(); + assertEquals(parent.getId(), child.getParentStageId()); + assertEquals("42." + child.getId(), child.getPath()); + } + + @Test + void reportStage_WithUnknownParentKey_ShouldThrowBadRequest() { + persistedIteration(10L); + + assertThrows(ReportPortalException.class, () -> sut.reportStage(projectId, 10L, "child", + PipelineStageRQ.builder().status("Running").parentStageKey("nope").build())); + } + + @Test + void reportStage_ForExistingStageUnderADifferentParent_ShouldBeRejected() { + persistedIteration(10L); + persistedStage(10L, "other-parent", "Running", StatusCategory.RUNNING); + var stage = persistedStage(10L, "child", "Running", StatusCategory.RUNNING); + stage.setParentStageId(1L); + + assertThrows(ReportPortalException.class, () -> sut.reportStage(projectId, 10L, "child", + PipelineStageRQ.builder().status("Passed").parentStageKey("other-parent").build())); + } + + @Test + void reportStage_FromTheSameJobAgain_ShouldUpdateTheCurrentAttemptInPlace() { + var iteration = persistedIteration(10L); + var stage = persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITLAB_CI").externalRunId("77").build())); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Passed") + .execution(PipelineExecution.builder().executorType("GITLAB_CI").externalRunId("77").build()).build()); + + assertEquals(1, stage.getAttempt()); + assertEquals("Passed", stage.getStatus()); + assertEquals(StatusCategory.PASSED, stage.getStatusCategory()); + verify(pipelineStageAttemptRepository, never()).save(any()); + verify(refresher).refresh(iteration); + } + + @Test + void reportStage_OfAFinishedStageByAnotherJob_ShouldKeepTheFinishedAttemptAndStartANewOne() { + persistedIteration(10L); + var stage = persistedStage(10L, "upload", "Failed", StatusCategory.FAILED); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITLAB_CI").externalRunId("77").build())); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running") + .execution(PipelineExecution.builder().executorType("GITLAB_CI").externalRunId("78").build()).build()); + + var captor = ArgumentCaptor.forClass(PipelineStageAttempt.class); + verify(pipelineStageAttemptRepository).save(captor.capture()); + assertEquals(1, captor.getValue().getAttempt()); + assertEquals("Failed", captor.getValue().getStatus()); + assertEquals(2, stage.getAttempt()); + assertEquals(StatusCategory.RUNNING, stage.getStatusCategory()); + } + + @Test + void reportStage_WithRelatedEntities_ShouldHandThemToTheResolver() { + persistedIteration(10L); + persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running") + .sections(PipelineTestData.sectionLinking(relatedTestCases("TC-1"))).build()); + + verify(testCaseResolver).resolve(eq(projectId), eq(pipeline), any(), eq(List.of("TC-1"))); + } + + @Test + void reportStage_ShouldSignalAPassToTheHandlersOnlyWhenTheStageBecomesPassed() { + persistedIteration(10L); + persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running").build()); + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Passed").build()); + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Passed").build()); + + var reports = ArgumentCaptor.forClass(PipelineStageReportedHandler.StageReport.class); + verify(stageHandler, org.mockito.Mockito.times(3)).onStageReported(reports.capture()); + assertEquals(List.of(false, true, false), + reports.getAllValues().stream().map(PipelineStageReportedHandler.StageReport::stagePassed).toList()); + } + + @Test + void reportStage_WhenTheStageWasRelatedAndTheReportLinksNothing_ShouldLetTheResolverClearIt() { + persistedIteration(10L); + persistedStage(10L, "upload", "Running", StatusCategory.RUNNING).setRelatedEntities( + new com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineRelatedEntities( + relatedTestCases("TC-1"))); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running").build()); + + verify(testCaseResolver).resolve(eq(projectId), eq(pipeline), any(), eq(List.of())); + } + + @Test + void reportStage_WithoutRefs_ShouldLeaveWhatTheStageReferencesAlone() { + persistedIteration(10L); + persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Passed").build()); + + verify(testCaseResolver, never()).resolve(any(), any(), any(), any()); + } + + @Test + void reportStage_WithMismatchingKeyInBody_ShouldThrowBadRequest() { + persistedIteration(10L); + + assertThrows(ReportPortalException.class, + () -> sut.reportStage(projectId, 10L, "grade", PipelineStageRQ.builder().stageKey("other").status("x").build())); + } + + @Test + void reportStage_ForUnknownIteration_ShouldThrowNotFound() { + when(pipelineIterationRepository.findByIdForUpdate(10L)).thenReturn(Optional.empty()); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.reportStage(projectId, 10L, "grade", stageRQ(null, "Running", null))); + + assertEquals(ErrorType.NOT_FOUND.getCode(), ex.getErrorType().getCode()); + } + + @Test + void reportStage_ForIterationOfAnotherProject_ShouldThrowNotFound() { + persistedIteration(10L); + + assertThrows(ReportPortalException.class, + () -> sut.reportStage(999L, 10L, "grade", stageRQ(null, "Running", null))); + } + + @Test + void reportStage_ForSystemManagedStage_ShouldBeRejected() { + persistedIteration(10L); + persistedStage(10L, "review", "In progress", StatusCategory.WAITING).setOwnedBy(StageOwner.REPORTPORTAL); + + assertThrows(ReportPortalException.class, + () -> sut.reportStage(projectId, 10L, "review", stageRQ(null, "Done", null))); + } + + @Test + void patch_ShouldMergeAttributesRemoveKeysAndSetTimes() { + var iteration = persistedIteration(10L); + iteration.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("env").value("beta4").build(), + PipelineAttribute.builder().key("jira").value("A-1").build()))); + var finished = java.time.Instant.parse("2026-01-01T10:00:00Z"); + + sut.patchPipelineIteration(projectId, 10L, PipelineIterationPatchRQ.builder() + .attributes(List.of(PipelineAttribute.builder().key("env").value("beta5").build(), + PipelineAttribute.builder().key("model").value("auto").build())) + .removeAttributes(List.of("jira")) + .finishedAt(finished).build()); + + assertEquals(List.of("env", "model"), + iteration.getAttributes().getItems().stream().map(PipelineAttribute::getKey).toList()); + assertEquals("beta5", iteration.getAttributes().getItems().get(0).getValue()); + assertEquals("beta5 auto", iteration.getSearchText()); + assertEquals(finished, iteration.getFinishedAt()); + verify(refresher).refresh(iteration); + } + + @Test + void patch_WithNothingToChange_ShouldLeaveAttributesAlone() { + var iteration = persistedIteration(10L); + var attributes = new PipelineAttributes(List.of(PipelineAttribute.builder().key("env").value("beta4").build())); + iteration.setAttributes(attributes); + + sut.patchPipelineIteration(projectId, 10L, new PipelineIterationPatchRQ()); + + assertSame(attributes, iteration.getAttributes()); + assertNull(iteration.getFinishedAt()); + } + + // ------------------------------------------------------------------ reads + + @Test + @SuppressWarnings("unchecked") + void getPipelineIterations_ShouldPageThroughTheAssemblerAndCapTheLimit() { + var iteration = new PipelineIteration(); + iteration.setId(1L); + var summary = PipelineIterationSummaryRS.builder().id(1L).build(); + when(pipelineRepository.findById(pipeline.getId())).thenReturn(Optional.of(pipeline)); + when(pipelineIterationRepository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(iteration))); + when(viewAssembler.summaries(List.of(iteration))).thenReturn(List.of(summary)); + + Page page = sut.getPipelineIterations(projectId, pipeline.getId(), + PipelineIterationFilter.none(), new OffsetRequest(0, 1000, org.springframework.data.domain.Sort.unsorted())); + + assertEquals(List.of(summary), page.getContent()); + var pageable = ArgumentCaptor.forClass(Pageable.class); + verify(pipelineIterationRepository).findAll(any(Specification.class), pageable.capture()); + assertEquals(PipelinePaging.MAX_LIMIT, pageable.getValue().getPageSize()); + } + + @Test + void getPipelineIterations_ForPipelineOfAnotherProject_ShouldThrowNotFound() { + when(pipelineRepository.findById(pipeline.getId())).thenReturn(Optional.of(pipeline)); + + assertThrows(ReportPortalException.class, () -> sut.getPipelineIterations(999L, pipeline.getId(), + PipelineIterationFilter.none(), new OffsetRequest(0, 10, org.springframework.data.domain.Sort.unsorted()))); + } + + @Test + void getPipelineIteration_ShouldReturnTheAssembledDetailAndHideOtherProjects() { + persistedIteration(10L); + + assertSame(renderedDetail, sut.getPipelineIteration(projectId, 10L)); + assertThrows(ReportPortalException.class, () -> sut.getPipelineIteration(999L, 10L)); + } + + @Test + void compare_ShouldCompareCandidateAgainstBaselineOfTheSamePipeline() { + var candidate = persistedIteration(10L); + var baseline = persistedIteration(9L); + var baselineDetail = PipelineIterationDetailRS.builder().id(9L).build(); + var candidateDetail = PipelineIterationDetailRS.builder().id(10L).build(); + when(viewAssembler.detail(baseline)).thenReturn(baselineDetail); + when(viewAssembler.detail(candidate)).thenReturn(candidateDetail); + var compared = PipelineCompareRS.builder().build(); + when(comparator.compare(baselineDetail, candidateDetail)).thenReturn(compared); + + assertSame(compared, sut.comparePipelineIterations(projectId, 10L, 9L)); + } + + @Test + void compare_OfIterationsFromDifferentPipelines_ShouldThrowBadRequest() { + persistedIteration(10L); + var other = new Pipeline(); + other.setId(77L); + other.setProject(project); + var baseline = new PipelineIteration(); + baseline.setId(9L); + baseline.setPipeline(other); + when(pipelineIterationRepository.findById(9L)).thenReturn(Optional.of(baseline)); + + assertThrows(ReportPortalException.class, () -> sut.comparePipelineIterations(projectId, 10L, 9L)); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelinePagingTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelinePagingTest.java new file mode 100644 index 0000000000..8b9a95b768 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelinePagingTest.java @@ -0,0 +1,35 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.epam.reportportal.base.util.OffsetRequest; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Sort; + +class PipelinePagingTest { + + private static final Sort SORT = Sort.by("name"); + + @Test + void capLimit_WhenWithinMax_ShouldKeepLimitAndOffset() { + var request = PipelinePaging.capLimit(new OffsetRequest(10, 50, Sort.by("ignored")), SORT); + + assertEquals(50, request.getPageSize()); + assertEquals(10, request.getOffset()); + } + + @Test + void capLimit_WhenOverMax_ShouldClampLimitAndKeepOffset() { + var capped = PipelinePaging.capLimit(new OffsetRequest(30, 5000), SORT); + + assertEquals(PipelinePaging.MAX_LIMIT, capped.getPageSize()); + assertEquals(30, capped.getOffset()); + } + + @Test + void capLimit_ShouldReplaceCallerSortWithTheGivenOne() { + var capped = PipelinePaging.capLimit(new OffsetRequest(0, 10, Sort.by("ignored")), SORT); + + assertEquals(SORT, capped.getSort()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidatorTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidatorTest.java new file mode 100644 index 0000000000..676340fb34 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidatorTest.java @@ -0,0 +1,58 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.configs.JsonSchemaValidatorConfig; +import com.epam.reportportal.base.util.JsonSchemaValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class PipelineRawOutputValidatorTest { + + private PipelineRawOutputValidator validator; + + @BeforeEach + void setUp() { + var config = new JsonSchemaValidatorConfig(); + ReflectionTestUtils.setField(config, "schemaVersion", "V7"); + ReflectionTestUtils.setField(config, "schemaSource", "https://schema.reportportal.io/"); + ReflectionTestUtils.setField(config, "schemaLocation", "classpath:schema/"); + validator = new PipelineRawOutputValidator(new JsonSchemaValidator(config), new ObjectMapper()); + } + + @Test + void validate_WithAValidGradeRawOutput_ShouldHaveNoReason() { + var rawOutput = Map.of("total_score", 83, "warnings", List.of("w"), + "cases", List.of(Map.of("name", "Case", "total_score", 42))); + + assertTrue(validator.validate("grade", rawOutput).isEmpty()); + } + + @Test + void validate_WithAGradeRawOutputWithoutSuiteScore_ShouldNameTheProblem() { + var reason = validator.validate("grade", Map.of("warnings", List.of())); + + assertTrue(reason.isPresent()); + assertTrue(reason.get().contains("total_score"), reason.get()); + } + + @Test + void validate_WithAWronglyTypedCaseScore_ShouldBeRejected() { + var rawOutput = Map.of("total_score", 83, + "cases", List.of(Map.of("name", "Case", "total_score", "high"))); + + assertTrue(validator.validate("grade", rawOutput).isPresent()); + } + + @Test + void validate_WithoutASchemaOrRawOutputOrWithAnUnsafeReference_ShouldNotCheck() { + assertTrue(validator.validate("no-such-schema", Map.of("a", 1)).isEmpty()); + assertTrue(validator.validate(null, Map.of("a", 1)).isEmpty()); + assertTrue(validator.validate("grade", null).isEmpty()); + assertTrue(validator.validate("../grade", Map.of("a", 1)).isEmpty()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImplTest.java new file mode 100644 index 0000000000..2ed3882775 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImplTest.java @@ -0,0 +1,201 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.dto.PipelineRS; +import com.epam.reportportal.base.core.aifactory.dto.PipelineSettingsRQ; +import com.epam.reportportal.base.core.aifactory.mapper.PipelineMapper; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineSettings; +import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import com.epam.reportportal.base.util.OffsetRequest; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; + +@ExtendWith(MockitoExtension.class) +class PipelineServiceImplTest { + + @Mock + private PipelineRepository pipelineRepository; + @Mock + private PipelineIterationRepository pipelineIterationRepository; + @Mock + private PipelineMapper pipelineMapper; + @Mock + private PipelineSettingsValidator settingsValidator; + @Spy + private List settingsValidators = new ArrayList<>(); + + @InjectMocks + private PipelineServiceImpl sut; + + private Long projectId; + private Long pipelineId; + private Pipeline pipeline; + + @BeforeEach + void setUp() { + projectId = 1L; + pipelineId = 2L; + settingsValidators.add(settingsValidator); + + var project = new Project(); + project.setId(projectId); + + pipeline = new Pipeline(); + pipeline.setId(pipelineId); + pipeline.setProject(project); + pipeline.setName("factory-tc-gen"); + } + + private void givenPipelineIsSaved(PipelineRS expected) { + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); + when(pipelineRepository.save(pipeline)).thenReturn(pipeline); + when(pipelineIterationRepository.countByPipelineId(pipelineId)).thenReturn(0L); + when(pipelineMapper.toPipelineRS(pipeline, 0L)).thenReturn(expected); + } + + @Test + void patchSettings_ShouldStoreTheGivenSettingsCheckThemAndReturnThePipeline() { + var expectedRS = PipelineRS.builder().id(pipelineId).build(); + givenPipelineIsSaved(expectedRS); + var autoReady = Map.of("enabled", true, "threshold", 90); + var rq = PipelineSettingsRQ.builder().settings(Map.of("autoReady", autoReady)).build(); + + var result = sut.patchPipelineSettings(projectId, pipelineId, rq); + + assertEquals(expectedRS, result); + assertEquals(Map.of("autoReady", autoReady), pipeline.getSettings().getValues()); + verify(settingsValidator).validate(pipeline, Map.of("autoReady", autoReady)); + verify(pipelineRepository).save(pipeline); + } + + @Test + void patchSettings_ShouldReplaceNamedSettingsKeepTheOthersAndRemoveOnesSentAsNull() { + givenPipelineIsSaved(PipelineRS.builder().id(pipelineId).build()); + pipeline.setSettings(new PipelineSettings(Map.of("autoReady", Map.of("enabled", false), "other", "kept", + "obsolete", "x"))); + var changes = new HashMap(); + changes.put("autoReady", Map.of("enabled", true, "threshold", 80)); + changes.put("obsolete", null); + + sut.patchPipelineSettings(projectId, pipelineId, PipelineSettingsRQ.builder().settings(changes).build()); + + assertEquals(Map.of("autoReady", Map.of("enabled", true, "threshold", 80), "other", "kept"), + pipeline.getSettings().getValues()); + } + + @Test + void patchSettings_WhenEverySettingIsRemoved_ShouldStoreNoSettings() { + givenPipelineIsSaved(PipelineRS.builder().id(pipelineId).build()); + pipeline.setSettings(new PipelineSettings(Map.of("autoReady", Map.of("enabled", false)))); + var changes = new HashMap(); + changes.put("autoReady", null); + + sut.patchPipelineSettings(projectId, pipelineId, PipelineSettingsRQ.builder().settings(changes).build()); + + assertNull(pipeline.getSettings()); + } + + @Test + void patchSettings_WhenAValidatorRejectsThem_ShouldNotSave() { + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); + doThrow(new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "threshold is required")) + .when(settingsValidator).validate(any(), any()); + var rq = PipelineSettingsRQ.builder().settings(Map.of("autoReady", Map.of("enabled", true))).build(); + + assertThrows(ReportPortalException.class, () -> sut.patchPipelineSettings(projectId, pipelineId, rq)); + verify(pipelineRepository, never()).save(any()); + } + + @Test + void patchSettings_WhenPipelineBelongsToDifferentProject_ShouldThrowNotFound() { + var otherProject = new Project(); + otherProject.setId(999L); + pipeline.setProject(otherProject); + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); + + assertThrows(ReportPortalException.class, + () -> sut.patchPipelineSettings(projectId, pipelineId, PipelineSettingsRQ.builder().settings(Map.of()).build())); + verify(pipelineRepository, never()).save(any()); + } + + @Test + void patchSettings_WhenPipelineNotFound_ShouldThrowNotFound() { + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.empty()); + + assertThrows(ReportPortalException.class, + () -> sut.patchPipelineSettings(projectId, pipelineId, PipelineSettingsRQ.builder().settings(Map.of()).build())); + verify(pipelineRepository, never()).save(any()); + } + + @Test + void listPipelines_ShouldBatchIterationCountsAcrossThePage() { + var offsetRequest = new OffsetRequest(0, 50); + var page = new PageImpl<>(List.of(pipeline)); + var expectedRS = PipelineRS.builder().id(pipelineId).build(); + + when(pipelineRepository.findAll(org.mockito.ArgumentMatchers.>any(), any(Pageable.class))) + .thenReturn(page); + when(pipelineIterationRepository.countByPipelineIdIn(List.of(pipelineId))) + .thenReturn(List.of(iterationCount(pipelineId, 3L))); + when(pipelineMapper.toPipelineRS(pipeline, 3L)).thenReturn(expectedRS); + + var result = sut.getPipelines(projectId, null, offsetRequest); + + assertEquals(List.of(expectedRS), result.getContent()); + verify(pipelineIterationRepository, never()).countByPipelineId(any()); + } + + @Test + void listPipelines_WithoutIterations_ShouldStillReturnThePipelineWithZeroCount() { + var page = new PageImpl<>(List.of(pipeline)); + var expectedRS = PipelineRS.builder().id(pipelineId).build(); + when(pipelineRepository.findAll(org.mockito.ArgumentMatchers.>any(), any(Pageable.class))) + .thenReturn(page); + when(pipelineIterationRepository.countByPipelineIdIn(List.of(pipelineId))).thenReturn(List.of()); + when(pipelineMapper.toPipelineRS(pipeline, 0L)).thenReturn(expectedRS); + + var result = sut.getPipelines(projectId, null, new OffsetRequest(0, 50)); + + assertEquals(List.of(expectedRS), result.getContent()); + } + + private PipelineIterationRepository.PipelineIterationCount iterationCount(Long pipelineId, Long count) { + return new PipelineIterationRepository.PipelineIterationCount() { + @Override + public Long getPipelineId() { + return pipelineId; + } + + @Override + public Long getCount() { + return count; + } + }; + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImplTest.java new file mode 100644 index 0000000000..2e40121600 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImplTest.java @@ -0,0 +1,259 @@ +package com.epam.reportportal.base.core.aifactory.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.connector.CiTriggerConnector; +import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRetryRQ; +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.event.PipelineStageRetryRequestedEvent; +import com.epam.reportportal.base.infrastructure.persistence.dao.IntegrationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageAttemptRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineStageRepository; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineExecutionData; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStageAttempt; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.Integration; +import com.epam.reportportal.base.infrastructure.persistence.entity.integration.IntegrationType; +import com.epam.reportportal.base.infrastructure.persistence.entity.project.Project; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; + +/** + * Covers the transactional-boundary split: the external CI call must happen between the two DB + * transactions, not inside one wrapping both. + */ +@ExtendWith(MockitoExtension.class) +class PipelineStageRetryServiceImplTest { + + @Mock + private PipelineStageRepository pipelineStageRepository; + @Mock + private PipelineStageAttemptRepository pipelineStageAttemptRepository; + @Mock + private PipelineIterationRefresher iterationRefresher; + @Mock + private IntegrationRepository integrationRepository; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private PlatformTransactionManager transactionManager; + @Mock + private CiTriggerConnector githubConnector; + + private PipelineStageRetryServiceImpl sut; + + private PipelineStage stage; + private Integration integration; + + @BeforeEach + void setUp() { + when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class)); + when(githubConnector.getSupportedExecutorType()).thenReturn("GITHUB_ACTIONS"); + + sut = new PipelineStageRetryServiceImpl(pipelineStageRepository, pipelineStageAttemptRepository, + iterationRefresher, integrationRepository, eventPublisher, List.of(githubConnector), transactionManager); + + var project = new Project(); + project.setId(1L); + + var pipeline = new Pipeline(); + pipeline.setId(2L); + pipeline.setProject(project); + + var iteration = new PipelineIteration(); + iteration.setId(3L); + iteration.setPipeline(pipeline); + + stage = new PipelineStage(); + stage.setId(4L); + stage.setIteration(iteration); + stage.setStatus("Needs human"); + stage.setStatusCategory(StatusCategory.WAITING); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITHUB_ACTIONS").retryable(true).build())); + + var integrationType = new IntegrationType(); + integrationType.setName("github-actions"); + integration = new Integration(); + integration.setEnabled(true); + integration.setType(integrationType); + } + + @Test + void retryStage_WhenRetryable_ShouldTriggerConnectorThenPersistPendingStatus() { + stubClaim(); + when(githubConnector.triggerStageRerun(integration, stage)).thenReturn("https://ci/runs/1"); + + var result = sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, PipelineStageRetryRQ.builder().comment("retry").build()); + + assertEquals(4L, result.getStageId()); + assertEquals("Pending", result.getStatus()); + assertEquals(StatusCategory.PENDING, result.getStatusCategory()); + assertEquals("https://ci/runs/1", result.getTriggeredRunUrl()); + assertEquals("Pending", stage.getStatus()); + assertEquals(StatusCategory.PENDING, stage.getStatusCategory()); + assertEquals(9L, stage.getLastRetriedBy()); + verify(eventPublisher).publishEvent(any(PipelineStageRetryRequestedEvent.class)); + } + + @Test + void retryStage_WhenStageWasFailed_ShouldRefreshTheIterationStatus() { + stage.setStatus("Failed"); + stage.setStatusCategory(StatusCategory.FAILED); + stubClaim(); + when(githubConnector.triggerStageRerun(integration, stage)).thenReturn("https://ci/runs/1"); + + sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null); + + verify(iterationRefresher).refresh(stage.getIteration()); + } + + @Test + void retryStage_WhenStagePassedOrSkipped_ShouldThrowBadRequestWithoutTriggeringConnector() { + for (var category : List.of(StatusCategory.PASSED, StatusCategory.SKIPPED)) { + stage.setStatusCategory(category); + when(pipelineStageRepository.findByIdForUpdate(4L)).thenReturn(Optional.of(stage)); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + } + verify(githubConnector, never()).triggerStageRerun(any(), any()); + verify(pipelineStageRepository, never()).save(any()); + } + + @Test + void retryStage_WhenStageAlreadyPendingOrRunning_ShouldThrowConflictWithoutTriggeringConnector() { + for (var category : List.of(StatusCategory.PENDING, StatusCategory.RUNNING)) { + stage.setStatusCategory(category); + when(pipelineStageRepository.findByIdForUpdate(4L)).thenReturn(Optional.of(stage)); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + assertEquals(ErrorType.PIPELINE_STAGE_RETRY_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + verify(githubConnector, never()).triggerStageRerun(any(), any()); + verify(pipelineStageRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void retryStage_WhenStatusUnknownOrStageMaintainedByTheSystem_ShouldNotBeRetryable() { + stage.setStatusCategory(StatusCategory.UNKNOWN); + when(pipelineStageRepository.findByIdForUpdate(4L)).thenReturn(Optional.of(stage)); + assertThrows(ReportPortalException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + stage.setStatusCategory(StatusCategory.FAILED); + stage.setOwnedBy(StageOwner.REPORTPORTAL); + assertThrows(ReportPortalException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + verify(githubConnector, never()).triggerStageRerun(any(), any()); + } + + @Test + void retryStage_WhenConnectorFails_ShouldRestoreStageStatusAndRefreshTheIteration() { + stage.setStatus("Failed"); + stage.setStatusCategory(StatusCategory.FAILED); + stubClaim(); + when(githubConnector.triggerStageRerun(integration, stage)).thenThrow(new IllegalStateException("ci down")); + + assertThrows(IllegalStateException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + assertEquals("Failed", stage.getStatus()); + assertEquals(StatusCategory.FAILED, stage.getStatusCategory()); + verify(iterationRefresher, org.mockito.Mockito.times(2)).refresh(stage.getIteration()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void retryStage_WhenFailed_ShouldArchiveFinishedAttemptAndStartANewOne() { + stage.setStatus("Failed"); + stage.setStatusCategory(StatusCategory.FAILED); + stage.setStageKey("upload"); + stage.setAttempt(1); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITHUB_ACTIONS").externalRunId("job-7").retryable(true).build())); + stage.setStartedAt(java.time.Instant.parse("2026-01-01T10:00:00Z")); + stage.setFinishedAt(java.time.Instant.parse("2026-01-01T10:01:00Z")); + stubClaim(); + when(githubConnector.triggerStageRerun(integration, stage)).thenReturn("https://ci/runs/1"); + + sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null); + + var archived = org.mockito.ArgumentCaptor.forClass(PipelineStageAttempt.class); + verify(pipelineStageAttemptRepository).save(archived.capture()); + assertEquals(1, archived.getValue().getAttempt()); + assertEquals("Failed", archived.getValue().getStatus()); + assertEquals(StatusCategory.FAILED, archived.getValue().getStatusCategory()); + assertEquals("upload", archived.getValue().getStageKey()); + assertEquals("job-7", archived.getValue().getExecution().getContent().getExternalRunId()); + assertEquals(2, stage.getAttempt()); + assertNull(stage.getStartedAt()); + assertNull(stage.getFinishedAt()); + } + + @Test + void retryStage_WhenConnectorFails_ShouldNotArchiveOrBumpAttempt() { + stage.setStatus("Failed"); + stage.setStatusCategory(StatusCategory.FAILED); + stubClaim(); + when(githubConnector.triggerStageRerun(integration, stage)).thenThrow(new IllegalStateException("ci down")); + + assertThrows(IllegalStateException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + verify(pipelineStageAttemptRepository, never()).save(any()); + assertEquals(1, stage.getAttempt()); + } + + @Test + void retryStage_WhenNotRetryable_ShouldThrowBadRequestWithoutTriggeringConnectorOrSaving() { + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITHUB_ACTIONS").retryable(false).build())); + when(pipelineStageRepository.findByIdForUpdate(4L)).thenReturn(Optional.of(stage)); + + assertThrows(ReportPortalException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + verify(githubConnector, never()).triggerStageRerun(any(), any()); + verify(pipelineStageRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void retryStage_WhenNoEnabledIntegration_ShouldThrowBadRequestWithoutTriggeringConnector() { + when(pipelineStageRepository.findByIdForUpdate(4L)).thenReturn(Optional.of(stage)); + when(integrationRepository.findAllProjectByGroup(any(), any())).thenReturn(List.of()); + + assertThrows(ReportPortalException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); + + verify(githubConnector, never()).triggerStageRerun(any(), any()); + verify(pipelineStageRepository, never()).save(any()); + } + + private void stubClaim() { + when(pipelineStageRepository.findByIdForUpdate(4L)).thenReturn(Optional.of(stage)); + lenient().when(pipelineStageRepository.findById(4L)).thenReturn(Optional.of(stage)); + when(integrationRepository.findAllProjectByGroup(any(), any())).thenReturn(List.of(integration)); + when(githubConnector.getIntegrationTypeName()).thenReturn("github-actions"); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsQualityStandardIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsQualityStandardIntegrationTest.java new file mode 100644 index 0000000000..c7c8913daf --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsQualityStandardIntegrationTest.java @@ -0,0 +1,157 @@ +package com.epam.reportportal.base.core.tms.controller.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsQualityStandardRepository; +import com.epam.reportportal.base.ws.BaseMvcTest; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; + +@Sql("/db/tms/tms-quality-standard/tms-quality-standard-fill.sql") +class TmsQualityStandardIntegrationTest extends BaseMvcTest { + + private static final String SUPERADMIN_PROJECT_KEY = "superadmin_personal"; + private static final String DEFAULT_PROJECT_KEY = "default_personal"; + + @Autowired + private TmsQualityStandardRepository tmsQualityStandardRepository; + + private final ObjectMapper mapper = new ObjectMapper(); + + private TmsQualityStandardRQ validRq() { + return TmsQualityStandardRQ.builder() + .name("New rubric") + .description("Freshly created") + .criteria(List.of( + TmsQualityStandardCriterionRQ.builder().name("Correctness").maxPoints(70).sequence(1).build(), + TmsQualityStandardCriterionRQ.builder().name("Clarity").maxPoints(30).sequence(2).build())) + .build(); + } + + @Test + void getStandard_WhenExists_ShouldReturnItWithCriteria() throws Exception { + mockMvc.perform(get("/v1/project/" + DEFAULT_PROJECT_KEY + "/tms/quality-standard") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(900)) + .andExpect(jsonPath("$.name").value("Default TC quality rubric")) + .andExpect(jsonPath("$.criteria.length()").value(2)) + .andExpect(jsonPath("$.criteria[0].name").value("Scenario correctness")) + .andExpect(jsonPath("$.criteria[0].maxPoints").value(60)); + } + + @Test + void getStandard_WhenNotFound_ShouldReturnNotFound() throws Exception { + mockMvc.perform(get("/v1/project/" + SUPERADMIN_PROJECT_KEY + "/tms/quality-standard") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isNotFound()); + } + + @Test + void createStandard_WhenNoneExists_ShouldPersistAndReturnCreated() throws Exception { + var rq = validRq(); + + mockMvc.perform(post("/v1/project/" + SUPERADMIN_PROJECT_KEY + "/tms/quality-standard") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("New rubric")) + .andExpect(jsonPath("$.criteria.length()").value(2)); + + var created = tmsQualityStandardRepository.findByProjectId(1L); + assertTrue(created.isPresent()); + assertEquals("New rubric", created.get().getName()); + assertEquals(2, created.get().getCriteria().size()); + } + + @Test + void createStandard_WhenAlreadyExists_ShouldReturnConflictAndNotModifyExisting() throws Exception { + var rq = validRq(); + + mockMvc.perform(post("/v1/project/" + DEFAULT_PROJECT_KEY + "/tms/quality-standard") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isConflict()); + + var existing = tmsQualityStandardRepository.findByProjectId(2L).orElseThrow(); + assertEquals("Default TC quality rubric", existing.getName()); + } + + @Test + void createStandard_WithCriteriaNotSummingToOneHundred_ShouldReturnBadRequest() throws Exception { + var rq = TmsQualityStandardRQ.builder() + .name("Bad rubric") + .criteria(List.of(TmsQualityStandardCriterionRQ.builder().name("c1").maxPoints(50).sequence(1).build())) + .build(); + + mockMvc.perform(post("/v1/project/" + SUPERADMIN_PROJECT_KEY + "/tms/quality-standard") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isBadRequest()); + + assertFalse(tmsQualityStandardRepository.findByProjectId(1L).isPresent()); + } + + @Test + void updateStandard_WhenExists_ShouldMergeCriteriaAndPersist() throws Exception { + var rq = TmsQualityStandardRQ.builder() + .name("Updated rubric") + .description("Updated") + .criteria(List.of( + TmsQualityStandardCriterionRQ.builder().id(900L).name("Scenario correctness").maxPoints(80).sequence(1).build(), + TmsQualityStandardCriterionRQ.builder().name("New criterion").maxPoints(20).sequence(2).build())) + .build(); + + mockMvc.perform(put("/v1/project/" + DEFAULT_PROJECT_KEY + "/tms/quality-standard") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated rubric")) + .andExpect(jsonPath("$.criteria.length()").value(2)); + + var updated = tmsQualityStandardRepository.findByProjectId(2L).orElseThrow(); + assertEquals("Updated rubric", updated.getName()); + assertEquals(2, updated.getCriteria().size()); + // Criterion 900 kept its id (merged in place), the removed criterion 901 is gone. + assertTrue(updated.getCriteria().stream().anyMatch(c -> c.getId().equals(900L) && c.getMaxPoints() == 80)); + assertFalse(updated.getCriteria().stream().anyMatch(c -> c.getId().equals(901L))); + } + + @Test + void updateStandard_WhenNotFound_ShouldReturnNotFound() throws Exception { + var rq = validRq(); + + mockMvc.perform(put("/v1/project/" + SUPERADMIN_PROJECT_KEY + "/tms/quality-standard") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(rq)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isNotFound()); + } + + @Test + void deleteStandard_WhenExists_ShouldRemoveItAndItsCriteria() throws Exception { + mockMvc.perform(delete("/v1/project/" + DEFAULT_PROJECT_KEY + "/tms/quality-standard") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()); + + assertFalse(tmsQualityStandardRepository.findByProjectId(2L).isPresent()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsTestPlanIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsTestPlanIntegrationTest.java index 2d34f9da30..77590e86c7 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsTestPlanIntegrationTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsTestPlanIntegrationTest.java @@ -275,6 +275,26 @@ void addTestCasesToPlanIntegrationTest() throws Exception { .andExpect(jsonPath("$.errors").isArray()); } + @Test + void addTestCasesToPlan_WhenTestCaseIsNotReady_ShouldReportNotReadyAndOnlyAddReadyOnes() throws Exception { + var draft = testCaseRepository.findById(8L).orElseThrow(); + draft.setStatus(com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus.DRAFT); + testCaseRepository.saveAndFlush(draft); + var addRequest = BatchAddTestCasesToPlanRQ.builder().testCaseIds(List.of(7L, 8L)).build(); + + mockMvc + .perform( + post("/v1/project/" + SUPERADMIN_PROJECT_KEY + "/tms/test-plan/4/test-case/batch") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(addRequest)) + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)) + .andExpect(jsonPath("$.failureCount").value(1)) + .andExpect(jsonPath("$.errors[0].testCaseId").value(8)) + .andExpect(jsonPath("$.errors[0].reason").value("NOT_READY")); + } + @Test void addSingleTestCaseToPlanIntegrationTest() throws Exception { // Given diff --git a/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsQualityStandardControllerTest.java b/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsQualityStandardControllerTest.java new file mode 100644 index 0000000000..008b1dc992 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsQualityStandardControllerTest.java @@ -0,0 +1,167 @@ +package com.epam.reportportal.base.core.tms.controller.unit; + +import static com.epam.reportportal.base.util.StandaloneMockMvcSupport.standaloneJsonSetup; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epam.reportportal.base.core.tms.controller.TmsQualityStandardController; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRS; +import com.epam.reportportal.base.core.tms.service.TmsQualityStandardService; +import com.epam.reportportal.base.infrastructure.persistence.commons.ReportPortalUser; +import com.epam.reportportal.base.infrastructure.persistence.entity.organization.MembershipDetails; +import com.epam.reportportal.base.util.ProjectExtractor; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +class TmsQualityStandardControllerTest { + + private final long projectId = 1L; + private final String projectKey = "test_project"; + + @Mock + private TmsQualityStandardService tmsQualityStandardService; + @Mock + private ProjectExtractor projectExtractor; + + @InjectMocks + private TmsQualityStandardController controller; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private ReportPortalUser testUser; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + objectMapper = new ObjectMapper(); + + testUser = ReportPortalUser.userBuilder() + .withUserName("testUser") + .withPassword("password") + .withUserId(1L) + .withActive(true) + .withAuthorities(Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))) + .build(); + + mockMvc = standaloneJsonSetup(controller) + .setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.getParameterAnnotation(AuthenticationPrincipal.class) != null; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return testUser; + } + }) + .build(); + + var membershipDetails = MembershipDetails.builder() + .withProjectId(projectId) + .withProjectKey(projectKey) + .build(); + given(projectExtractor.extractMembershipDetails(eq(testUser), anyString())).willReturn(membershipDetails); + } + + private TmsQualityStandardRQ validRq() { + return TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .description("6-criteria, sum/100") + .criteria(List.of(TmsQualityStandardCriterionRQ.builder() + .name("Scenario correctness").maxPoints(100).sequence(1).build())) + .build(); + } + + @Test + void getStandard_ShouldDelegateToServiceAndReturnIt() throws Exception { + var rs = TmsQualityStandardRS.builder().id(5L).name("Default TC quality rubric").build(); + given(tmsQualityStandardService.getStandard(projectId)).willReturn(rs); + + mockMvc.perform(get("/v1/project/{projectKey}/tms/quality-standard", projectKey)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(5)) + .andExpect(jsonPath("$.name").value("Default TC quality rubric")); + + verify(tmsQualityStandardService).getStandard(projectId); + } + + @Test + void createStandard_ShouldDelegateToServiceAndReturnCreated() throws Exception { + var rq = validRq(); + var rs = TmsQualityStandardRS.builder().id(5L).name(rq.getName()).build(); + given(tmsQualityStandardService.createStandard(eq(projectId), org.mockito.ArgumentMatchers.any())).willReturn(rs); + + mockMvc.perform(post("/v1/project/{projectKey}/tms/quality-standard", projectKey) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(5)); + + verify(tmsQualityStandardService).createStandard(eq(projectId), org.mockito.ArgumentMatchers.any()); + } + + @Test + void createStandard_WithCriteriaNotSummingToOneHundred_ShouldReturnBadRequestWithoutCallingService() throws Exception { + var rq = TmsQualityStandardRQ.builder() + .name("Bad rubric") + .criteria(List.of(TmsQualityStandardCriterionRQ.builder().name("c1").maxPoints(50).sequence(1).build())) + .build(); + + mockMvc.perform(post("/v1/project/{projectKey}/tms/quality-standard", projectKey) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isBadRequest()); + + org.mockito.Mockito.verifyNoInteractions(tmsQualityStandardService); + } + + @Test + void updateStandard_ShouldDelegateToServiceAndReturnUpdated() throws Exception { + var rq = validRq(); + var rs = TmsQualityStandardRS.builder().id(5L).name(rq.getName()).build(); + given(tmsQualityStandardService.updateStandard(eq(projectId), org.mockito.ArgumentMatchers.any())).willReturn(rs); + + mockMvc.perform(put("/v1/project/{projectKey}/tms/quality-standard", projectKey) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(5)); + + verify(tmsQualityStandardService).updateStandard(eq(projectId), org.mockito.ArgumentMatchers.any()); + } + + @Test + void deleteStandard_ShouldDelegateToService() throws Exception { + mockMvc.perform(delete("/v1/project/{projectKey}/tms/quality-standard", projectKey)) + .andExpect(status().isOk()); + + verify(tmsQualityStandardService).deleteStandard(projectId); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsTestCaseControllerTest.java b/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsTestCaseControllerTest.java index 1c295e9b4b..4a9933defa 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsTestCaseControllerTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsTestCaseControllerTest.java @@ -24,6 +24,9 @@ import com.epam.reportportal.base.core.tms.dto.TmsStepRQ; import com.epam.reportportal.base.core.tms.dto.TmsStepsManualScenarioRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseAttributeRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationMetadataRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.dto.TmsTestFolderRS; @@ -44,6 +47,7 @@ import com.epam.reportportal.base.ws.resolver.OffsetArgumentResolver; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletResponse; +import java.math.BigDecimal; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -569,6 +573,51 @@ void patchTestCase_WithTags() throws Exception { verify(tmsTestCaseService).patch(membershipDetails, testUser, testCaseId, testCaseRequest); } + // ------------------------------------------------------------------------- + // POST generation — projectId, dedicated AI-scoring endpoint + // ------------------------------------------------------------------------- + + @Test + void applyGenerationTest() throws Exception { + var testCaseId = 2L; + var generationRequest = TmsTestCaseGenerationRQ.builder() + .qualityScores(List.of(TmsTestCaseQualityScoreRQ.builder().criterionId(11L).score(20).build())) + .generation(TmsTestCaseGenerationMetadataRQ.builder() + .model("claude-opus-5") + .costUsd(new BigDecimal("0.98")) + .build()) + .build(); + var testCase = new TmsTestCaseRS(); + given(tmsTestCaseService.applyGeneration(projectId, testCaseId, generationRequest)) + .willReturn(testCase); + + mockMvc.perform( + post("/v1/project/{projectKey}/tms/test-case/{testCaseId}/generation", projectKey, testCaseId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(generationRequest))) + .andExpect(status().isOk()); + + verify(projectExtractor).extractMembershipDetails(eq(testUser), anyString()); + verify(tmsTestCaseService).applyGeneration(projectId, testCaseId, generationRequest); + } + + @Test + void applyGenerationWithEmptyBodyTest() throws Exception { + var testCaseId = 2L; + var generationRequest = TmsTestCaseGenerationRQ.builder().build(); + var testCase = new TmsTestCaseRS(); + given(tmsTestCaseService.applyGeneration(projectId, testCaseId, generationRequest)) + .willReturn(testCase); + + mockMvc.perform( + post("/v1/project/{projectKey}/tms/test-case/{testCaseId}/generation", projectKey, testCaseId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(generationRequest))) + .andExpect(status().isOk()); + + verify(tmsTestCaseService).applyGeneration(projectId, testCaseId, generationRequest); + } + // ------------------------------------------------------------------------- // DELETE single — membership + user instead of projectId // ------------------------------------------------------------------------- diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassedTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassedTest.java new file mode 100644 index 0000000000..0e3339e520 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassedTest.java @@ -0,0 +1,92 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeRollup; +import com.epam.reportportal.base.core.aifactory.service.PipelineStageReportedHandler.StageReport; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineSettings; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AutoReadyOnStagePassedTest { + + private static final Long PROJECT_ID = 1L; + + @Mock + private PipelineStageTestCaseRepository stageTestCaseRepository; + @Mock + private TmsTestCaseService tmsTestCaseService; + + @InjectMocks + private AutoReadyOnStagePassed handler; + + private Pipeline pipeline; + private PipelineStage stage; + + @BeforeEach + void setUp() { + pipeline = new Pipeline(); + stage = new PipelineStage(); + stage.setId(30L); + } + + private void autoReady(Object value) { + pipeline.setSettings(new PipelineSettings(Map.of("autoReady", value))); + } + + @Test + void onStageReported_WhenTheStagePassedAndAutoReadyIsOn_ShouldPromoteAndAnswerWithTheCount() { + autoReady(Map.of("enabled", true, "threshold", 90)); + var testCases = List.of(new TmsTestCase(), new TmsTestCase()); + when(stageTestCaseRepository.findTestCasesByStageId(30L)).thenReturn(testCases); + when(tmsTestCaseService.applyAutoReadyBatch(PROJECT_ID, testCases, 90)).thenReturn(1); + + var attributes = handler.onStageReported(new StageReport(PROJECT_ID, pipeline, stage, true)); + + assertEquals("autoReadyPromoted", attributes.get(0).getKey()); + assertEquals("1", attributes.get(0).getValue()); + assertEquals(AttributeRollup.SUM, attributes.get(0).getRollup()); + assertEquals("autoReadyThreshold", attributes.get(1).getKey()); + assertEquals("90", attributes.get(1).getValue()); + } + + @Test + void onStageReported_WhenTheStageDidNotJustPass_ShouldDoNothing() { + autoReady(Map.of("enabled", true, "threshold", 90)); + + var attributes = handler.onStageReported(new StageReport(PROJECT_ID, pipeline, stage, false)); + + assertTrue(attributes.isEmpty()); + verify(tmsTestCaseService, never()).applyAutoReadyBatch(any(), anyCollection(), anyInt()); + } + + @Test + void onStageReported_WhenAutoReadyIsOffOrMissing_ShouldDoNothing() { + assertTrue(handler.onStageReported(new StageReport(PROJECT_ID, pipeline, stage, true)).isEmpty()); + + autoReady(Map.of("enabled", false, "threshold", 90)); + assertTrue(handler.onStageReported(new StageReport(PROJECT_ID, pipeline, stage, true)).isEmpty()); + + verify(tmsTestCaseService, never()).applyAutoReadyBatch(eq(PROJECT_ID), anyCollection(), anyInt()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidatorTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidatorTest.java new file mode 100644 index 0000000000..5aac3ed396 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidatorTest.java @@ -0,0 +1,51 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AutoReadySettingsValidatorTest { + + private final AutoReadySettingsValidator validator = new AutoReadySettingsValidator(); + private final Pipeline pipeline = new Pipeline(); + + @Test + void validate_WithEnabledAndAThresholdInRange_ShouldAccept() { + assertDoesNotThrow(() -> validator.validate(pipeline, + Map.of("autoReady", Map.of("enabled", true, "threshold", 90)))); + assertDoesNotThrow(() -> validator.validate(pipeline, + Map.of("autoReady", Map.of("enabled", true, "threshold", 0)))); + assertDoesNotThrow(() -> validator.validate(pipeline, + Map.of("autoReady", Map.of("enabled", true, "threshold", 100)))); + } + + @Test + void validate_WhenDisabledOrAbsent_ShouldNotRequireAThreshold() { + assertDoesNotThrow(() -> validator.validate(pipeline, Map.of("autoReady", Map.of("enabled", false)))); + assertDoesNotThrow(() -> validator.validate(pipeline, Map.of())); + } + + @Test + void validate_WhenEnabledWithoutAThresholdOrOutOfRange_ShouldReject() { + assertThrows(ReportPortalException.class, + () -> validator.validate(pipeline, Map.of("autoReady", Map.of("enabled", true)))); + assertThrows(ReportPortalException.class, () -> validator.validate(pipeline, + Map.of("autoReady", Map.of("enabled", true, "threshold", 101)))); + assertThrows(ReportPortalException.class, () -> validator.validate(pipeline, + Map.of("autoReady", Map.of("enabled", true, "threshold", -1)))); + } + + @Test + void validate_WhenTheSettingIsNotAnObject_ShouldReject() { + assertThrows(ReportPortalException.class, () -> validator.validate(pipeline, Map.of("autoReady", "yes"))); + } + + @Test + void validate_ShouldIgnoreSettingsThatAreNotItsOwn() { + assertDoesNotThrow(() -> validator.validate(pipeline, Map.of("somethingElse", "anything"))); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChangeTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChangeTest.java new file mode 100644 index 0000000000..d62c770291 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChangeTest.java @@ -0,0 +1,65 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.service.PipelineIterationRefresher; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +class RefreshPipelineIterationsOnTestCaseChangeTest { + + private final PipelineStageTestCaseRepository stageTestCaseRepository = + Mockito.mock(PipelineStageTestCaseRepository.class); + private final PipelineIterationRefresher refresher = Mockito.mock(PipelineIterationRefresher.class); + private final RefreshPipelineIterationsOnTestCaseChange listener = + new RefreshPipelineIterationsOnTestCaseChange(stageTestCaseRepository, refresher); + + @Test + void onLifecycleChanged_WithoutATransaction_ShouldRefreshTheLinkedIterationsAtOnce() { + when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(Set.of(5L))).thenReturn(List.of(7L)); + + listener.onLifecycleChanged(new TmsTestCasesLifecycleChangedEvent(List.of(5L))); + + verify(refresher).refreshIterations(List.of(7L)); + } + + @Test + void onLifecycleChanged_InsideATransaction_ShouldRefreshOnlyAfterTheCommit() { + when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(Set.of(5L))).thenReturn(List.of(7L)); + TransactionSynchronizationManager.initSynchronization(); + try { + listener.onLifecycleChanged(new TmsTestCasesLifecycleChangedEvent(List.of(5L))); + + verify(refresher, never()).refreshIterations(any()); + TransactionSynchronizationManager.getSynchronizations().forEach(TransactionSynchronization::afterCommit); + verify(refresher).refreshIterations(List.of(7L)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void onLifecycleChanged_WhenNoIterationIsLinked_ShouldDoNothing() { + when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(Set.of(5L))).thenReturn(List.of()); + + listener.onLifecycleChanged(new TmsTestCasesLifecycleChangedEvent(List.of(5L))); + + verify(refresher, never()).refreshIterations(any()); + } + + @Test + void onLifecycleChanged_WithNoTestCases_ShouldNotEvenLookUp() { + listener.onLifecycleChanged(new TmsTestCasesLifecycleChangedEvent(List.of())); + + verify(stageTestCaseRepository, never()).findIterationIdsByTestCaseIdIn(any()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolverTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolverTest.java new file mode 100644 index 0000000000..89f5228531 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolverTest.java @@ -0,0 +1,87 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.PipelineStageTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TestCaseRelatedEntityResolverTest { + + private static final Long PROJECT_ID = 1L; + + @Mock + private TmsTestCaseService tmsTestCaseService; + @Mock + private PipelineStageTestCaseRepository stageTestCaseRepository; + + @InjectMocks + private TestCaseRelatedEntityResolver resolver; + + private Pipeline pipeline; + private PipelineStage stage; + + @BeforeEach + void setUp() { + pipeline = new Pipeline(); + stage = new PipelineStage(); + stage.setId(30L); + } + + private static TmsTestCase testCase(String displayId) { + var testCase = new TmsTestCase(); + testCase.setDisplayId(displayId); + return testCase; + } + + @Test + void entityType_ShouldBeTestCase() { + assertEquals("TEST_CASE", resolver.entityType()); + } + + @Test + @SuppressWarnings("unchecked") + void resolve_ShouldReplaceLinksAndReturnTheDisplayIdsThatDoNotExist() { + when(tmsTestCaseService.getEntitiesByDisplayIds(eq(PROJECT_ID), anyCollection())) + .thenReturn(List.of(testCase("TC-1"))); + + var notFound = resolver.resolve(PROJECT_ID, pipeline, stage, List.of("TC-1", "TC-404")); + + assertEquals(List.of("TC-404"), notFound); + verify(stageTestCaseRepository).deleteByStage_Id(30L); + var saved = ArgumentCaptor.forClass(List.class); + verify(stageTestCaseRepository).saveAll(saved.capture()); + var links = (List) saved.getValue(); + assertEquals(1, links.size()); + assertEquals("TC-1", links.get(0).getTestCase().getDisplayId()); + assertEquals(stage, links.get(0).getStage()); + } + + @Test + void resolve_WithNoDisplayIds_ShouldOnlyClearTheStagesLinks() { + var notFound = resolver.resolve(PROJECT_ID, pipeline, stage, List.of()); + + assertTrue(notFound.isEmpty()); + verify(stageTestCaseRepository).deleteByStage_Id(30L); + verify(tmsTestCaseService, never()).getEntitiesByDisplayIds(any(), anyCollection()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributorTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributorTest.java new file mode 100644 index 0000000000..e7ecf6c275 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributorTest.java @@ -0,0 +1,235 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.aifactory.enums.StageOwner; +import com.epam.reportportal.base.core.aifactory.enums.StatusCategory; +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineStage; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TestCaseReviewContributorTest { + + @Mock + private PipelineStageTestCaseRepository stageTestCaseRepository; + + @InjectMocks + private TestCaseReviewContributor contributor; + + private PipelineIteration iteration; + + @BeforeEach + void setUp() { + iteration = new PipelineIteration(); + iteration.setId(7L); + } + + private static PipelineStage stage(String key, int sequence, StatusCategory category) { + var stage = new PipelineStage(); + stage.setId((long) sequence); + stage.setStageKey(key); + stage.setSequence(sequence); + stage.setStatus(category.defaultLabel()); + stage.setStatusCategory(category); + stage.setOwnedBy(StageOwner.PIPELINE); + return stage; + } + + private static PipelineStageTestCaseRepository.IterationTestCaseCounts counts(long total, long ready) { + return new PipelineStageTestCaseRepository.IterationTestCaseCounts() { + @Override + public Long getIterationId() { + return 7L; + } + + @Override + public Long getTestCasesCount() { + return total; + } + + @Override + public Long getReadyCount() { + return ready; + } + }; + } + + private static String attribute(PipelineStage stage, String key) { + return stage.getAttributes().getItems().stream().filter(a -> key.equals(a.getKey())) + .map(PipelineAttribute::getValue).findFirst().orElse(null); + } + + @Test + void supports_OnlyTestCaseGenerationPipelines() { + var generation = new Pipeline(); + generation.setType("TEST_CASE_GENERATION"); + var other = new Pipeline(); + other.setType("TEST_AUTOMATION"); + + assertTrue(contributor.supports(generation)); + assertFalse(contributor.supports(other)); + assertFalse(contributor.supports(new Pipeline())); + } + + @Test + void contribute_BeforeUploadPassed_ShouldCreatePendingReviewThatDoesNotCount() { + var stages = List.of(stage("create", 1, StatusCategory.PASSED), stage("upload", 3, StatusCategory.FAILED)); + + var result = contributor.contribute(iteration, stages); + + assertEquals(1, result.size()); + var review = result.get(0); + assertNull(review.getId()); + assertEquals("review", review.getStageKey()); + assertEquals("Review", review.getName()); + assertEquals(4, review.getSequence()); + assertEquals(StageOwner.REPORTPORTAL, review.getOwnedBy()); + assertEquals(StatusCategory.PENDING, review.getStatusCategory()); + assertFalse(review.isAffectsIterationStatus()); + assertSame(iteration, review.getIteration()); + } + + private static void assertSame(Object expected, Object actual) { + org.junit.jupiter.api.Assertions.assertSame(expected, actual); + } + + @Test + void contribute_WithUploadPassedAndDraftCases_ShouldBeInProgressWithReadyOverTotal() { + var stages = List.of(stage("upload", 3, StatusCategory.PASSED)); + when(stageTestCaseRepository.countTestCasesByIterationIdIn(anyCollection(), eq(TmsTestCaseStatus.READY))) + .thenReturn(List.of(counts(4, 1))); + + var review = contributor.contribute(iteration, stages).get(0); + + assertEquals("In progress", review.getStatus()); + assertEquals(StatusCategory.WAITING, review.getStatusCategory()); + assertTrue(review.isAffectsIterationStatus()); + assertEquals("1/4", attribute(review, "review")); + assertEquals("1", attribute(review, "ready")); + assertEquals("1/4 Ready", review.getSummary()); + assertEquals("4", attribute(review, "total")); + assertNotNull(review.getStartedAt()); + assertNull(review.getFinishedAt()); + } + + @Test + void contribute_ShouldDescribeWhatIsPendingInTheStageMessage() { + var stages = List.of(stage("upload", 3, StatusCategory.PASSED)); + when(stageTestCaseRepository.countTestCasesByIterationIdIn(anyCollection(), eq(TmsTestCaseStatus.READY))) + .thenReturn(List.of(counts(4, 1)), List.of(counts(4, 3)), List.of(counts(4, 4))); + + assertEquals("3 Draft Test Cases wait for review in the Test Case Library", + contributor.contribute(iteration, stages).get(0).getMessage()); + assertEquals("1 Draft Test Case waits for review in the Test Case Library", + contributor.contribute(iteration, stages).get(0).getMessage()); + assertEquals("All Test Cases of this iteration are Ready", + contributor.contribute(iteration, stages).get(0).getMessage()); + } + + @Test + void contribute_BeforeUploadPassed_ShouldSayItIsWaitingForUpload() { + assertEquals("Waiting for Upload", contributor.contribute(iteration, + List.of(stage("upload", 3, StatusCategory.RUNNING))).get(0).getMessage()); + } + + @Test + void iterationAttributes_ShouldExposeReadyNowFromTheReviewStage() { + var stages = new java.util.ArrayList<>(List.of(stage("upload", 3, StatusCategory.PASSED))); + when(stageTestCaseRepository.countTestCasesByIterationIdIn(anyCollection(), eq(TmsTestCaseStatus.READY))) + .thenReturn(List.of(counts(4, 1))); + stages.add(contributor.contribute(iteration, stages).get(0)); + + var attributes = contributor.iterationAttributes(iteration, stages); + + assertEquals(1, attributes.size()); + assertEquals("readyNow", attributes.get(0).getKey()); + assertEquals("1", attributes.get(0).getValue()); + } + + @Test + void iterationAttributes_BeforeUploadPassed_ShouldBeEmpty() { + var stages = new java.util.ArrayList<>(List.of(stage("upload", 3, StatusCategory.RUNNING))); + stages.add(contributor.contribute(iteration, stages).get(0)); + + assertTrue(contributor.iterationAttributes(iteration, stages).isEmpty()); + } + + @Test + void contribute_WhenAllCasesReady_ShouldBeDoneAndFinished() { + var stages = List.of(stage("upload", 3, StatusCategory.PASSED)); + when(stageTestCaseRepository.countTestCasesByIterationIdIn(anyCollection(), eq(TmsTestCaseStatus.READY))) + .thenReturn(List.of(counts(4, 4))); + + var review = contributor.contribute(iteration, stages).get(0); + + assertEquals("Done", review.getStatus()); + assertEquals(StatusCategory.PASSED, review.getStatusCategory()); + assertNotNull(review.getFinishedAt()); + assertEquals("4/4", attribute(review, "review")); + } + + @Test + void contribute_WhenACaseGoesBackToDraft_ShouldReopenAnExistingReviewStage() { + var existing = stage("review", 4, StatusCategory.PASSED); + existing.setOwnedBy(StageOwner.REPORTPORTAL); + existing.setFinishedAt(java.time.Instant.parse("2026-01-01T00:00:00Z")); + var stages = List.of(stage("upload", 3, StatusCategory.PASSED), existing); + when(stageTestCaseRepository.countTestCasesByIterationIdIn(anyCollection(), eq(TmsTestCaseStatus.READY))) + .thenReturn(List.of(counts(4, 3))); + + var result = contributor.contribute(iteration, stages); + + assertSame(existing, result.get(0)); + assertEquals("In progress", existing.getStatus()); + assertEquals(StatusCategory.WAITING, existing.getStatusCategory()); + assertNull(existing.getFinishedAt()); + } + + @Test + void contribute_WithNoLinkedCases_ShouldBeDone() { + var stages = List.of(stage("upload", 3, StatusCategory.PASSED)); + when(stageTestCaseRepository.countTestCasesByIterationIdIn(anyCollection(), eq(TmsTestCaseStatus.READY))) + .thenReturn(List.of()); + + var review = contributor.contribute(iteration, stages).get(0); + + assertEquals("Done", review.getStatus()); + assertEquals("0/0", attribute(review, "review")); + } + + @Test + void contribute_WhenThePipelineReportsItsOwnStageUnderTheKey_ShouldLeaveItAlone() { + var own = stage("review", 4, StatusCategory.RUNNING); + + var result = contributor.contribute(iteration, List.of(stage("upload", 3, StatusCategory.PASSED), own)); + + assertTrue(result.isEmpty()); + assertEquals(StatusCategory.RUNNING, own.getStatusCategory()); + } + + @Test + void iterationStatusLabel_ShouldUseReviewWording() { + assertEquals("In review", contributor.iterationStatusLabel(StatusCategory.WAITING).orElseThrow()); + assertEquals("Completed", contributor.iterationStatusLabel(StatusCategory.PASSED).orElseThrow()); + assertTrue(contributor.iterationStatusLabel(StatusCategory.FAILED).isEmpty()); + assertTrue(contributor.iterationStatusLabel(StatusCategory.RUNNING).isEmpty()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImplTest.java new file mode 100644 index 0000000000..35edf44af9 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImplTest.java @@ -0,0 +1,221 @@ +package com.epam.reportportal.base.core.tms.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRS; +import com.epam.reportportal.base.core.tms.mapper.TmsQualityStandardMapper; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsQualityStandardRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseQualityScoreRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandard; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandardCriterion; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TmsQualityStandardServiceImplTest { + + @Mock + private TmsQualityStandardRepository tmsQualityStandardRepository; + @Mock + private TmsTestCaseQualityScoreRepository tmsTestCaseQualityScoreRepository; + @Mock + private TmsQualityStandardMapper tmsQualityStandardMapper; + + @InjectMocks + private TmsQualityStandardServiceImpl sut; + + private Long projectId; + private TmsQualityStandardRQ rq; + + @BeforeEach + void setUp() { + projectId = 1L; + rq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .description("6-criteria, sum/100") + .criteria(List.of(TmsQualityStandardCriterionRQ.builder() + .name("Scenario correctness").maxPoints(25).sequence(1).build())) + .build(); + } + + @Test + void getStandard_WhenExists_ShouldReturnMappedRS() { + var standard = new TmsQualityStandard(); + standard.setId(5L); + standard.setProjectId(projectId); + var expected = TmsQualityStandardRS.builder().id(5L).build(); + + when(tmsQualityStandardRepository.findByProjectId(projectId)).thenReturn(Optional.of(standard)); + when(tmsQualityStandardMapper.toRS(standard)).thenReturn(expected); + + var result = sut.getStandard(projectId); + + assertEquals(expected, result); + } + + @Test + void getStandard_WhenNotFound_ShouldThrowNotFound() { + when(tmsQualityStandardRepository.findByProjectId(projectId)).thenReturn(Optional.empty()); + + assertThrows(ReportPortalException.class, () -> sut.getStandard(projectId)); + } + + @Test + void createStandard_WhenNoneExists_ShouldCreateStandardWithProjectIdAndMergedCriteria() { + var criteria = List.of(new TmsQualityStandardCriterion()); + var expected = TmsQualityStandardRS.builder().id(5L).build(); + + when(tmsQualityStandardRepository.existsByProjectId(projectId)).thenReturn(false); + when(tmsQualityStandardMapper.mergeCriteria(any(), eq(rq.getCriteria()))).thenReturn(criteria); + when(tmsQualityStandardRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(tmsQualityStandardMapper.toRS(any())).thenReturn(expected); + + var result = sut.createStandard(projectId, rq); + + assertEquals(expected, result); + + var standardCaptor = ArgumentCaptor.forClass(TmsQualityStandard.class); + verify(tmsQualityStandardRepository).save(standardCaptor.capture()); + assertEquals(projectId, standardCaptor.getValue().getProjectId()); + assertEquals("Default TC quality rubric", standardCaptor.getValue().getName()); + assertEquals(criteria, standardCaptor.getValue().getCriteria()); + } + + @Test + void createStandard_WhenAlreadyExists_ShouldThrowResourceAlreadyExistsWithoutSaving() { + when(tmsQualityStandardRepository.existsByProjectId(projectId)).thenReturn(true); + + assertThrows(ReportPortalException.class, () -> sut.createStandard(projectId, rq)); + verify(tmsQualityStandardRepository, never()).save(any()); + } + + @Test + void updateStandard_WhenExists_ShouldMergeCriteriaOntoExistingStandardAndSave() { + var standard = new TmsQualityStandard(); + standard.setId(5L); + standard.setProjectId(projectId); + standard.setCriteria(new java.util.ArrayList<>(List.of(new TmsQualityStandardCriterion()))); + var mergedCriteria = List.of(new TmsQualityStandardCriterion(), new TmsQualityStandardCriterion()); + var expected = TmsQualityStandardRS.builder().id(5L).build(); + + when(tmsQualityStandardRepository.findByProjectId(projectId)).thenReturn(Optional.of(standard)); + when(tmsQualityStandardMapper.mergeCriteria(standard, rq.getCriteria())).thenReturn(mergedCriteria); + when(tmsQualityStandardRepository.save(standard)).thenReturn(standard); + when(tmsQualityStandardMapper.toRS(standard)).thenReturn(expected); + + var result = sut.updateStandard(projectId, rq); + + assertEquals(expected, result); + assertEquals("Default TC quality rubric", standard.getName()); + assertEquals(mergedCriteria, standard.getCriteria()); + } + + @Test + void updateStandard_WhenRemovingCriterionWithoutScores_ShouldMergeAndSave() { + var keptCriterion = new TmsQualityStandardCriterion(); + keptCriterion.setId(900L); + var removedCriterion = new TmsQualityStandardCriterion(); + removedCriterion.setId(901L); + var standard = new TmsQualityStandard(); + standard.setId(5L); + standard.setProjectId(projectId); + standard.setCriteria(new java.util.ArrayList<>(List.of(keptCriterion, removedCriterion))); + var keepOnlyRq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of(TmsQualityStandardCriterionRQ.builder() + .id(900L).name("Scenario correctness").maxPoints(100).sequence(1).build())) + .build(); + var mergedCriteria = List.of(keptCriterion); + var expected = TmsQualityStandardRS.builder().id(5L).build(); + + when(tmsQualityStandardRepository.findByProjectId(projectId)).thenReturn(Optional.of(standard)); + when(tmsTestCaseQualityScoreRepository.findDistinctCriterionIdsIn(java.util.Set.of(901L))) + .thenReturn(List.of()); + when(tmsQualityStandardMapper.mergeCriteria(standard, keepOnlyRq.getCriteria())).thenReturn(mergedCriteria); + when(tmsQualityStandardRepository.save(standard)).thenReturn(standard); + when(tmsQualityStandardMapper.toRS(standard)).thenReturn(expected); + + var result = sut.updateStandard(projectId, keepOnlyRq); + + assertEquals(expected, result); + assertEquals(mergedCriteria, standard.getCriteria()); + } + + @Test + void updateStandard_WhenRemovingCriterionWithExistingScores_ShouldRejectWithoutSaving() { + var keptCriterion = new TmsQualityStandardCriterion(); + keptCriterion.setId(900L); + var removedCriterion = new TmsQualityStandardCriterion(); + removedCriterion.setId(901L); + var standard = new TmsQualityStandard(); + standard.setId(5L); + standard.setProjectId(projectId); + standard.setCriteria(new java.util.ArrayList<>(List.of(keptCriterion, removedCriterion))); + var keepOnlyRq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of(TmsQualityStandardCriterionRQ.builder() + .id(900L).name("Scenario correctness").maxPoints(100).sequence(1).build())) + .build(); + + when(tmsQualityStandardRepository.findByProjectId(projectId)).thenReturn(Optional.of(standard)); + when(tmsTestCaseQualityScoreRepository.findDistinctCriterionIdsIn(java.util.Set.of(901L))) + .thenReturn(List.of(901L)); + + assertThrows(ReportPortalException.class, () -> sut.updateStandard(projectId, keepOnlyRq)); + verify(tmsQualityStandardRepository, never()).save(any()); + } + + @Test + void createStandard_WithDuplicateCriterionKeys_ShouldThrowBadRequestWithoutSaving() { + var duplicated = TmsQualityStandardRQ.builder().name("x") + .criteria(List.of( + TmsQualityStandardCriterionRQ.builder().name("A").key("clarity").maxPoints(10).sequence(1).build(), + TmsQualityStandardCriterionRQ.builder().name("B").key("clarity").maxPoints(10).sequence(2).build())) + .build(); + when(tmsQualityStandardRepository.existsByProjectId(projectId)).thenReturn(false); + + assertThrows(ReportPortalException.class, () -> sut.createStandard(projectId, duplicated)); + + verify(tmsQualityStandardRepository, never()).save(any()); + } + + @Test + void updateStandard_WhenNotFound_ShouldThrowNotFoundWithoutSaving() { + when(tmsQualityStandardRepository.findByProjectId(projectId)).thenReturn(Optional.empty()); + + assertThrows(ReportPortalException.class, () -> sut.updateStandard(projectId, rq)); + verify(tmsQualityStandardRepository, never()).save(any()); + } + + @Test + void deleteStandard_ShouldDelegateToRepository() { + sut.deleteStandard(projectId); + + verify(tmsQualityStandardRepository).deleteByProjectId(projectId); + } + + @Test + void deleteStandard_WhenCriteriaHaveScores_ShouldThrowAndNotDelete() { + when(tmsTestCaseQualityScoreRepository.existsByCriterionStandardProjectId(projectId)).thenReturn(true); + + assertThrows(ReportPortalException.class, () -> sut.deleteStandard(projectId)); + + verify(tmsQualityStandardRepository, never()).deleteByProjectId(any()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImplTest.java index cc8bd5f791..506067fec4 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImplTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseExecutionServiceImplTest.java @@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -21,6 +23,7 @@ import com.epam.reportportal.base.core.tms.dto.TmsTestCaseExecutionRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseTestFolderRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseStatus; import com.epam.reportportal.base.core.tms.mapper.NestedStepItemBuilder; import com.epam.reportportal.base.core.tms.mapper.TestCaseItemBuilder; import com.epam.reportportal.base.core.tms.mapper.TmsManualScenarioMapper; @@ -614,12 +617,14 @@ void addTestCasesToLaunch_WhenTestCasesProvided_ShouldPreFetchExecutionIdsAndSki .id(testCaseId1) .name("TC 1") .testFolder(TmsTestCaseTestFolderRS.builder().id(100L).build()) + .status(TmsTestCaseStatus.READY) .defaultVersionId(1L) .build(); var testCaseRS2 = TmsTestCaseRS.builder() .id(testCaseId2) .name("TC 2") .testFolder(TmsTestCaseTestFolderRS.builder().id(100L).build()) + .status(TmsTestCaseStatus.READY) .defaultVersionId(2L) .build(); @@ -655,6 +660,7 @@ void addTestCasesToLaunch_WhenPersistenceFails_ShouldPropagateException() { .id(testCaseId1) .name("TC 1") .testFolder(TmsTestCaseTestFolderRS.builder().id(100L).build()) + .status(TmsTestCaseStatus.READY) .build(); var suiteItem = new TestItem(); suiteItem.setItemId(50L); @@ -674,6 +680,152 @@ void addTestCasesToLaunch_WhenPersistenceFails_ShouldPropagateException() { () -> sut.addTestCasesToLaunch(projectId, launch, List.of(testCaseId1))); } + private TmsTestCaseRS readyTestCase(Long testCaseId) { + return TmsTestCaseRS.builder() + .id(testCaseId) + .name("Test Case " + testCaseId) + .status(TmsTestCaseStatus.READY) + .testFolder(TmsTestCaseTestFolderRS.builder().id(7L).build()) + .build(); + } + + private void stubSuccessfulExecutionCreation(Launch launch, TmsTestCaseRS testCase) { + var suiteItem = new TestItem(); + suiteItem.setItemId(500L); + var createdItem = new TestItem(); + createdItem.setItemId(501L); + var execution = TmsTestCaseExecution.builder().id(900L).testCaseId(testCase.getId()).build(); + + when(testFolderItemService.findTestFolderItem(eq(1L), eq(7L), eq(launch), any())).thenReturn(suiteItem); + when(testCaseItemService.createTestCaseItem(eq(testCase), eq(suiteItem), eq(launch), any())).thenReturn(createdItem); + when(tmsTestCaseVersionService.findDefaultVersionIdByTestCaseId(testCase.getId())).thenReturn(Optional.empty()); + when(tmsTestCaseExecutionMapper.createTestCaseExecution(testCase, launch, createdItem, null)).thenReturn(execution); + when(tmsTestCaseExecutionRepository.save(execution)).thenReturn(execution); + } + + @Test + void addTestCasesToLaunch_WhenTestCaseIsReadyAndNotYetInLaunch_ShouldCreateExecutionAndReportSuccess() { + var launch = new Launch(); + launch.setId(10L); + var testCase = readyTestCase(testCaseId1); + + when(tmsTestCaseService.getExistingTestCaseIds(1L, List.of(testCaseId1))).thenReturn(List.of(testCaseId1)); + when(tmsTestCaseExecutionRepository.findTestCaseIdsByLaunchId(10L)).thenReturn(Set.of()); + when(tmsTestCaseService.getByIdsMap(1L, List.of(testCaseId1), false)).thenReturn(Map.of(testCaseId1, testCase)); + stubSuccessfulExecutionCreation(launch, testCase); + + var result = sut.addTestCasesToLaunch(1L, launch, List.of(testCaseId1)); + + assertEquals(1, result.getTotalCount()); + assertEquals(1, result.getSuccessCount()); + assertEquals(0, result.getFailureCount()); + assertEquals(List.of(testCaseId1), result.getSuccessTestCaseIds()); + assertTrue(result.getErrors().isEmpty()); + } + + @Test + void addTestCasesToLaunch_WhenTestCaseDoesNotExistInProject_ShouldRecordErrorWithoutCallingGetById() { + var launch = new Launch(); + launch.setId(10L); + + when(tmsTestCaseService.getExistingTestCaseIds(1L, List.of(testCaseId1))).thenReturn(List.of()); + + var result = sut.addTestCasesToLaunch(1L, launch, List.of(testCaseId1)); + + assertEquals(1, result.getFailureCount()); + assertEquals("Test case does not exist in the project", result.getErrors().get(0).getErrorMessage()); + verify(tmsTestCaseService, never()).getByIdsMap(anyLong(), any(), anyBoolean()); + } + + @Test + void addTestCasesToLaunch_WhenExecutionAlreadyExistsInLaunch_ShouldRecordErrorWithoutCallingGetById() { + var launch = new Launch(); + launch.setId(10L); + + when(tmsTestCaseService.getExistingTestCaseIds(1L, List.of(testCaseId1))).thenReturn(List.of(testCaseId1)); + when(tmsTestCaseExecutionRepository.findTestCaseIdsByLaunchId(10L)).thenReturn(Set.of(testCaseId1)); + + var result = sut.addTestCasesToLaunch(1L, launch, List.of(testCaseId1)); + + assertEquals(1, result.getFailureCount()); + assertEquals("Test case execution already exists in launch", result.getErrors().get(0).getErrorMessage()); + verify(tmsTestCaseService, never()).getByIdsMap(anyLong(), any(), anyBoolean()); + } + + @Test + void addTestCasesToLaunch_WhenTestCaseIsNotReady_ShouldRecordErrorWithoutCreatingExecution() { + var launch = new Launch(); + launch.setId(10L); + var draftTestCase = TmsTestCaseRS.builder().id(testCaseId1).status(TmsTestCaseStatus.DRAFT).build(); + + when(tmsTestCaseService.getExistingTestCaseIds(1L, List.of(testCaseId1))).thenReturn(List.of(testCaseId1)); + when(tmsTestCaseExecutionRepository.findTestCaseIdsByLaunchId(10L)).thenReturn(Set.of()); + when(tmsTestCaseService.getByIdsMap(1L, List.of(testCaseId1), false)).thenReturn(Map.of(testCaseId1, draftTestCase)); + + var result = sut.addTestCasesToLaunch(1L, launch, List.of(testCaseId1)); + + assertEquals(1, result.getFailureCount()); + assertEquals("Test case is not Ready and cannot be added to a launch", result.getErrors().get(0).getErrorMessage()); + assertEquals(com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationErrorReason.NOT_READY, + result.getErrors().get(0).getReason()); + verifyNoInteractions(testFolderItemService, testCaseItemService); + } + + @Test + void addTestCasesToLaunch_WithMixOfReadyAndNotReadyTestCases_ShouldReportPartialSuccess() { + var launch = new Launch(); + launch.setId(10L); + var readyTestCase = readyTestCase(testCaseId1); + var draftTestCase = TmsTestCaseRS.builder().id(testCaseId2).status(TmsTestCaseStatus.DRAFT).build(); + + when(tmsTestCaseService.getExistingTestCaseIds(1L, List.of(testCaseId1, testCaseId2))) + .thenReturn(List.of(testCaseId1, testCaseId2)); + when(tmsTestCaseExecutionRepository.findTestCaseIdsByLaunchId(10L)).thenReturn(Set.of()); + when(tmsTestCaseService.getByIdsMap(1L, List.of(testCaseId1, testCaseId2), false)) + .thenReturn(Map.of(testCaseId1, readyTestCase, testCaseId2, draftTestCase)); + stubSuccessfulExecutionCreation(launch, readyTestCase); + + var result = sut.addTestCasesToLaunch(1L, launch, List.of(testCaseId1, testCaseId2)); + + assertEquals(2, result.getTotalCount()); + assertEquals(1, result.getSuccessCount()); + assertEquals(1, result.getFailureCount()); + assertEquals(List.of(testCaseId1), result.getSuccessTestCaseIds()); + assertEquals(testCaseId2, result.getErrors().get(0).getTestCaseId()); + } + + // ==================== addTestCaseToLaunch ==================== + + @Test + void addTestCaseToLaunch_WhenReady_ShouldCreateExecution() { + var launch = new Launch(); + launch.setId(10L); + var testCase = readyTestCase(testCaseId1); + + when(tmsTestCaseService.getById(1L, testCaseId1, false)).thenReturn(testCase); + when(tmsTestCaseExecutionRepository.existsByTestCaseIdAndLaunchId(testCaseId1, 10L)).thenReturn(false); + stubSuccessfulExecutionCreation(launch, testCase); + + sut.addTestCaseToLaunch(1L, launch, testCaseId1); + + verify(tmsTestCaseExecutionRepository).save(any(TmsTestCaseExecution.class)); + } + + @Test + void addTestCaseToLaunch_WhenNotReady_ShouldThrowBadRequestWithoutCreatingExecution() { + var launch = new Launch(); + launch.setId(10L); + var draftTestCase = TmsTestCaseRS.builder().id(testCaseId1).status(TmsTestCaseStatus.DRAFT).build(); + + when(tmsTestCaseService.getById(1L, testCaseId1, false)).thenReturn(draftTestCase); + + var exception = assertThrows(ReportPortalException.class, + () -> sut.addTestCaseToLaunch(1L, launch, testCaseId1)); + + assertTrue(exception.getMessage().contains("is not Ready")); + verifyNoInteractions(testFolderItemService, testCaseItemService); + } + // ==================== isTestCaseInLaunch ==================== @Test @@ -734,4 +886,4 @@ void patchTestCaseExecutionComment_WhenExecutionNotFound_ShouldThrowReportPortal () -> sut.patchTestCaseExecutionComment(projectId, launchId, executionId, request) ); } -} \ No newline at end of file +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImplTest.java new file mode 100644 index 0000000000..baf33afd06 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImplTest.java @@ -0,0 +1,347 @@ +package com.epam.reportportal.base.core.tms.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationMetadataRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsQualityStandardCriterionRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseGenerationMetadataRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseQualityScoreRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsQualityStandardCriterion; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseGenerationMetadata; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseQualityScore; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TmsTestCaseQualityServiceImplTest { + + @Mock + private TmsTestCaseQualityScoreRepository tmsTestCaseQualityScoreRepository; + @Mock + private TmsTestCaseGenerationMetadataRepository tmsTestCaseGenerationMetadataRepository; + @Mock + private TmsQualityStandardCriterionRepository tmsQualityStandardCriterionRepository; + + @InjectMocks + private TmsTestCaseQualityServiceImpl sut; + + private Long projectId; + private TmsTestCaseVersion version; + private TmsQualityStandardCriterion criterion; + + @BeforeEach + void setUp() { + projectId = 1L; + + version = new TmsTestCaseVersion(); + version.setId(10L); + + criterion = new TmsQualityStandardCriterion(); + criterion.setId(20L); + criterion.setMaxPoints(25); + } + + @Test + void applyQualityScores_WithScoreWithinRange_ShouldSave() { + var rq = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(20).build(); + + when(tmsQualityStandardCriterionRepository.findByIdAndStandard_ProjectId(20L, projectId)) + .thenReturn(Optional.of(criterion)); + + sut.applyQualityScores(projectId, version, List.of(rq)); + + verify(tmsTestCaseQualityScoreRepository).deleteByTestCaseVersionId(version.getId()); + verify(tmsTestCaseQualityScoreRepository).saveAll(any()); + } + + @Test + void applyQualityScores_WithScoreAboveCriterionMax_ShouldThrowBadRequest() { + var rq = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(26).build(); + + when(tmsQualityStandardCriterionRepository.findByIdAndStandard_ProjectId(20L, projectId)) + .thenReturn(Optional.of(criterion)); + + assertThrows(ReportPortalException.class, () -> sut.applyQualityScores(projectId, version, List.of(rq))); + verify(tmsTestCaseQualityScoreRepository, never()).saveAll(any()); + } + + @Test + void applyQualityScores_WithNegativeScore_ShouldThrowBadRequest() { + var rq = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(-1).build(); + + when(tmsQualityStandardCriterionRepository.findByIdAndStandard_ProjectId(20L, projectId)) + .thenReturn(Optional.of(criterion)); + + assertThrows(ReportPortalException.class, () -> sut.applyQualityScores(projectId, version, List.of(rq))); + verify(tmsTestCaseQualityScoreRepository, never()).saveAll(any()); + } + + @Test + void applyQualityScores_WithDuplicateCriterionId_ShouldThrowBadRequestBeforeDeleting() { + var rq1 = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(10).build(); + var rq2 = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(15).build(); + + when(tmsQualityStandardCriterionRepository.findByIdAndStandard_ProjectId(20L, projectId)) + .thenReturn(Optional.of(criterion)); + + assertThrows(ReportPortalException.class, + () -> sut.applyQualityScores(projectId, version, List.of(rq1, rq2))); + verify(tmsTestCaseQualityScoreRepository, never()).deleteByTestCaseVersionId(any()); + verify(tmsTestCaseQualityScoreRepository, never()).saveAll(any()); + } + + @Test + void applyQualityScores_WithCriterionKey_ShouldResolveCriterionByKey() { + criterion.setKey("clear_steps"); + var rq = TmsTestCaseQualityScoreRQ.builder().criterionKey("clear_steps").score(20).build(); + + when(tmsQualityStandardCriterionRepository.findByKeyAndStandard_ProjectId("clear_steps", projectId)) + .thenReturn(Optional.of(criterion)); + + sut.applyQualityScores(projectId, version, List.of(rq)); + + verify(tmsTestCaseQualityScoreRepository).saveAll(any()); + } + + @Test + void applyQualityScores_WithUnknownCriterionKey_ShouldThrowNotFound() { + var rq = TmsTestCaseQualityScoreRQ.builder().criterionKey("nope").score(1).build(); + + when(tmsQualityStandardCriterionRepository.findByKeyAndStandard_ProjectId("nope", projectId)) + .thenReturn(Optional.empty()); + + assertThrows(ReportPortalException.class, () -> sut.applyQualityScores(projectId, version, List.of(rq))); + verify(tmsTestCaseQualityScoreRepository, never()).saveAll(any()); + } + + @Test + void applyQualityScores_WithBothOrNeitherIdentifier_ShouldThrowBadRequest() { + var both = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).criterionKey("clear_steps").score(1).build(); + var neither = TmsTestCaseQualityScoreRQ.builder().score(1).build(); + + assertThrows(ReportPortalException.class, () -> sut.applyQualityScores(projectId, version, List.of(both))); + assertThrows(ReportPortalException.class, () -> sut.applyQualityScores(projectId, version, List.of(neither))); + verify(tmsTestCaseQualityScoreRepository, never()).saveAll(any()); + } + + @Test + void applyQualityScores_WithSameCriterionByIdAndByKey_ShouldThrowDuplicate() { + criterion.setKey("clear_steps"); + var byId = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(10).build(); + var byKey = TmsTestCaseQualityScoreRQ.builder().criterionKey("clear_steps").score(15).build(); + + when(tmsQualityStandardCriterionRepository.findByIdAndStandard_ProjectId(20L, projectId)) + .thenReturn(Optional.of(criterion)); + when(tmsQualityStandardCriterionRepository.findByKeyAndStandard_ProjectId("clear_steps", projectId)) + .thenReturn(Optional.of(criterion)); + + assertThrows(ReportPortalException.class, + () -> sut.applyQualityScores(projectId, version, List.of(byId, byKey))); + verify(tmsTestCaseQualityScoreRepository, never()).deleteByTestCaseVersionId(any()); + } + + @Test + void applyQualityScores_WithNullScores_ShouldBeNoOp() { + sut.applyQualityScores(projectId, version, null); + + verify(tmsTestCaseQualityScoreRepository, never()).deleteByTestCaseVersionId(any()); + verify(tmsTestCaseQualityScoreRepository, never()).saveAll(any()); + } + + @Test + void hasAiSignal_WithQualityScoresOnly_ShouldReturnTrue() { + var rq = TmsTestCaseQualityScoreRQ.builder().criterionId(20L).score(10).build(); + + assertTrue(sut.hasAiSignal(List.of(rq), null)); + } + + @Test + void hasAiSignal_WithGenerationMetadataOnly_ShouldReturnTrue() { + assertTrue(sut.hasAiSignal(null, TmsTestCaseGenerationMetadataRQ.builder().model("claude-opus-5").build())); + } + + @Test + void hasAiSignal_WithNeitherScoresNorGeneration_ShouldReturnFalse() { + assertFalse(sut.hasAiSignal(null, null)); + assertFalse(sut.hasAiSignal(List.of(), null)); + } + + @Test + void applyGenerationMetadata_WithNullGeneration_ShouldBeNoOp() { + sut.applyGenerationMetadata(version, null); + + verify(tmsTestCaseGenerationMetadataRepository, never()).findByTestCaseVersionId(any()); + verify(tmsTestCaseGenerationMetadataRepository, never()).save(any()); + } + + @Test + void applyGenerationMetadata_WhenNoExistingRow_ShouldCreateNewOne() { + var generation = TmsTestCaseGenerationMetadataRQ.builder() + .tokensIn(90000).tokensOut(42000).model("claude-opus-5").skill("create-test-cases@0.4") + .costUsd(BigDecimal.valueOf(0.98)).build(); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionId(version.getId())).thenReturn(Optional.empty()); + + sut.applyGenerationMetadata(version, generation); + + var captor = ArgumentCaptor.forClass(TmsTestCaseGenerationMetadata.class); + verify(tmsTestCaseGenerationMetadataRepository).save(captor.capture()); + assertEquals(version, captor.getValue().getTestCaseVersion()); + assertEquals(90000, captor.getValue().getTokensIn()); + assertEquals("claude-opus-5", captor.getValue().getModel()); + assertEquals(BigDecimal.valueOf(0.98), captor.getValue().getCostUsd()); + } + + @Test + void applyGenerationMetadata_WhenExistingRowForVersion_ShouldUpdateItInPlace() { + var existing = new TmsTestCaseGenerationMetadata(); + existing.setId(77L); + var generation = TmsTestCaseGenerationMetadataRQ.builder().model("claude-opus-5-2").build(); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionId(version.getId())).thenReturn(Optional.of(existing)); + + sut.applyGenerationMetadata(version, generation); + + var captor = ArgumentCaptor.forClass(TmsTestCaseGenerationMetadata.class); + verify(tmsTestCaseGenerationMetadataRepository).save(captor.capture()); + assertEquals(77L, captor.getValue().getId()); + assertEquals("claude-opus-5-2", captor.getValue().getModel()); + } + + @Test + void buildMetrics_WhenVersionIsNull_ShouldReturnNull() { + assertNull(sut.buildMetrics(null)); + } + + @Test + void buildMetrics_WhenNoScoresAndNoGeneration_ShouldReturnNull() { + when(tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + + assertNull(sut.buildMetrics(version)); + } + + @Test + void buildMetrics_WithScoresEvaluatedAfterVersionUpdate_ShouldSumOverallScoreAndNotBeObsolete() { + version.setUpdatedAt(Instant.parse("2026-09-01T00:00:00Z")); + var score = new TmsTestCaseQualityScore(); + score.setTestCaseVersion(version); + score.setCriterion(criterion); + score.setScore(20); + score.setEvaluatedAt(Instant.parse("2026-09-02T00:00:00Z")); + when(tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of(score)); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + + var result = sut.buildMetrics(version); + + assertEquals(20, result.getOverallScore()); + assertFalse(result.isObsolete()); + assertEquals(1, result.getCriteria().size()); + assertEquals(20L, result.getCriteria().get(0).getCriterionId()); + } + + @Test + void buildMetrics_WithScoresEvaluatedBeforeVersionUpdate_ShouldBeObsolete() { + version.setUpdatedAt(Instant.parse("2026-09-02T00:00:00Z")); + var score = new TmsTestCaseQualityScore(); + score.setTestCaseVersion(version); + score.setCriterion(criterion); + score.setScore(20); + score.setEvaluatedAt(Instant.parse("2026-09-01T00:00:00Z")); + when(tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of(score)); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + + var result = sut.buildMetrics(version); + + assertTrue(result.isObsolete()); + } + + @Test + void buildMetrics_WithGenerationOnlyAndNoScores_ShouldPopulateGenerationFieldsWithNullOverallScore() { + var generation = new TmsTestCaseGenerationMetadata(); + generation.setTokensIn(1000); + generation.setTestCaseVersion(version); + generation.setModel("claude-opus-5"); + when(tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(any())) + .thenReturn(List.of(generation)); + + var result = sut.buildMetrics(version); + + assertNull(result.getOverallScore()); + assertEquals(1000, result.getTokensIn()); + assertEquals("claude-opus-5", result.getModel()); + } + + @Test + void buildMetricsBatch_WithEmptyCollection_ShouldReturnEmptyMapWithoutQueryingRepositories() { + var result = sut.buildMetricsBatch(List.of()); + + assertTrue(result.isEmpty()); + verify(tmsTestCaseQualityScoreRepository, never()).findByTestCaseVersionIdIn(any()); + verify(tmsTestCaseGenerationMetadataRepository, never()).findByTestCaseVersionIdIn(any()); + } + + @Test + void buildMetricsBatch_ShouldGroupScoresAndGenerationPerVersionAndSkipVersionsWithNeither() { + var versionWithScore = new TmsTestCaseVersion(); + versionWithScore.setId(101L); + var versionWithNothing = new TmsTestCaseVersion(); + versionWithNothing.setId(102L); + + var scoreForVersion101 = new TmsTestCaseQualityScore(); + scoreForVersion101.setTestCaseVersion(versionWithScore); + scoreForVersion101.setCriterion(criterion); + scoreForVersion101.setScore(15); + scoreForVersion101.setEvaluatedAt(Instant.now()); + + when(tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of(scoreForVersion101)); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + + var result = sut.buildMetricsBatch(List.of(versionWithScore, versionWithNothing)); + + assertEquals(1, result.size()); + assertTrue(result.containsKey(101L)); + assertEquals(15, result.get(101L).getOverallScore()); + assertFalse(result.containsKey(102L)); + } + + @Test + void buildMetricsBatch_ShouldDeduplicateByVersionIdAndSkipNullVersions() { + var duplicate1 = new TmsTestCaseVersion(); + duplicate1.setId(101L); + var duplicate2 = new TmsTestCaseVersion(); + duplicate2.setId(101L); + when(tmsTestCaseQualityScoreRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + when(tmsTestCaseGenerationMetadataRepository.findByTestCaseVersionIdIn(any())).thenReturn(List.of()); + + var versions = new java.util.ArrayList(); + versions.add(duplicate1); + versions.add(duplicate2); + versions.add(null); + var result = sut.buildMetricsBatch(versions); + + assertTrue(result.isEmpty()); + var idsCaptor = ArgumentCaptor.forClass(java.util.Collection.class); + verify(tmsTestCaseQualityScoreRepository).findByTestCaseVersionIdIn(idsCaptor.capture()); + assertEquals(java.util.Set.of(101L), java.util.Set.copyOf(idsCaptor.getValue())); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImplTest.java index 48dcea772d..7b5cad4bae 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImplTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseServiceImplTest.java @@ -11,6 +11,7 @@ import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; @@ -29,9 +30,13 @@ import com.epam.reportportal.base.core.tms.dto.TmsStepsManualScenarioRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseAttributeImportRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseAttributeRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationMetadataRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseGenerationRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseImportParseResult; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseImportRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseInTestPlanRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseMetricsRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.dto.TmsTestFolderRS; @@ -59,6 +64,8 @@ import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseExecution; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestFolder; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; import com.epam.reportportal.base.model.activity.TestCaseActivityResource; @@ -138,6 +145,9 @@ class TmsTestCaseServiceImplTest { @Mock private TmsTestCaseActivityResourceMapper tmsTestCaseActivityResourceMapper; + @Mock + private TmsTestCaseQualityService tmsTestCaseQualityService; + @Mock private HttpServletResponse response; @@ -329,7 +339,7 @@ void getById_WhenTestCaseExists_ShouldReturnTestCase() { when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) .thenReturn(testCaseExecution); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) .thenReturn(testCaseRS); var result = sut.getById(projectId, testCaseId); @@ -339,7 +349,7 @@ void getById_WhenTestCaseExists_ShouldReturnTestCase() { verify(tmsTestCaseRepository).findByProjectIdAndId(projectId, testCaseId); verify(tmsTestCaseVersionService).getDefaultVersion(testCaseId); verify(tmsTestCaseExecutionService).getLastTestCaseExecution(testCaseId); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution, null); } @Test @@ -347,7 +357,7 @@ void getById_WithoutLastExecution_ShouldReturnTestCaseWithoutCallingExecutionSer when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) .thenReturn(Optional.of(testCase)); when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)) .thenReturn(testCaseRS); var result = sut.getById(projectId, testCaseId, false); @@ -357,7 +367,7 @@ void getById_WithoutLastExecution_ShouldReturnTestCaseWithoutCallingExecutionSer verify(tmsTestCaseRepository).findByProjectIdAndId(projectId, testCaseId); verify(tmsTestCaseVersionService).getDefaultVersion(testCaseId); verifyNoInteractions(tmsTestCaseExecutionService); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); } @Test @@ -367,10 +377,10 @@ void getByIdsMap_WithoutLastExecution_ShouldReturnMapWithoutCallingExecutionServ .thenReturn(List.of(testCase1, testCase2)); when(tmsTestCaseVersionService.getDefaultVersions(ids)) .thenReturn(Map.of(testCaseId1, version1, testCaseId2, version2)); - when(tmsTestCaseMapper.convert(testCase1, version1)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase1, version1, null)).thenReturn(testCaseRS); var testCaseRS2 = new TmsTestCaseRS(); testCaseRS2.setId(testCaseId2); - when(tmsTestCaseMapper.convert(testCase2, version2)).thenReturn(testCaseRS2); + when(tmsTestCaseMapper.convert(testCase2, version2, null)).thenReturn(testCaseRS2); var result = sut.getByIdsMap(projectId, ids, false); @@ -390,7 +400,7 @@ void getByIdsMap_WithDuplicateRepositoryResults_ShouldKeepOneResourcePerTestCase .thenReturn(List.of(testCase1, testCase1)); when(tmsTestCaseVersionService.getDefaultVersions(ids)) .thenReturn(Map.of(testCaseId1, version1)); - when(tmsTestCaseMapper.convert(testCase1, version1)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase1, version1, null)).thenReturn(testCaseRS); var result = sut.getByIdsMap(projectId, ids, false); @@ -418,6 +428,96 @@ void getById_WhenTestCaseDoesNotExist_ShouldThrowNotFoundException() { verify(tmsTestCaseRepository).findByProjectIdAndId(projectId, testCaseId); } + @Test + void getById_WhenTestCaseDoesNotExistInProject_ShouldNotLeakDefaultVersionLookup() { + // The project-scoped lookup must fail before the (project-unscoped) default-version lookup + // ever runs, otherwise a foreign id's error would differ depending on whether it happens to + // have a default version - an oracle for probing ids across projects. + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.empty()); + + assertThrows(ReportPortalException.class, () -> sut.getById(projectId, testCaseId)); + verify(tmsTestCaseVersionService, never()).getDefaultVersion(any()); + } + + @Test + void applyGeneration_WhenTestCaseNotFound_ShouldThrowNotFound() { + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.empty()); + var rq = TmsTestCaseGenerationRQ.builder().build(); + + assertThrows(ReportPortalException.class, () -> sut.applyGeneration(projectId, testCaseId, rq)); + verify(tmsTestCaseVersionService, never()).getDefaultVersion(any()); + } + + @Test + void applyGeneration_OnManualCaseWithAiSignal_ShouldRatchetOriginToAiAndDefaultStatusDraft() { + var qualityScores = List.of(TmsTestCaseQualityScoreRQ.builder().criterionId(11L).score(20).build()); + var rq = TmsTestCaseGenerationRQ.builder().qualityScores(qualityScores).build(); + + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseQualityService.hasAiSignal(qualityScores, null)).thenReturn(true); + when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) + .thenReturn(testCaseExecution); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) + .thenReturn(testCaseRS); + + var result = sut.applyGeneration(projectId, testCaseId, rq); + + assertEquals(testCaseRS, result); + assertEquals(TmsTestCaseOrigin.AI, testCase.getOrigin()); + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); + verify(tmsTestCaseQualityService).applyQualityScores(projectId, testCaseVersion, qualityScores); + verify(tmsTestCaseQualityService).applyGenerationMetadata(testCaseVersion, null); + verify(tmsTestCaseRepository).save(testCase); + } + + @Test + void applyGeneration_OnAlreadyAiCase_ShouldLeaveOriginAndStatusUnchanged() { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.READY); + var rq = TmsTestCaseGenerationRQ.builder() + .generation(TmsTestCaseGenerationMetadataRQ.builder().model("claude-opus-5").build()) + .build(); + + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) + .thenReturn(testCaseExecution); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) + .thenReturn(testCaseRS); + + sut.applyGeneration(projectId, testCaseId, rq); + + assertEquals(TmsTestCaseOrigin.AI, testCase.getOrigin()); + assertEquals(TmsTestCaseStatus.READY, testCase.getStatus()); + verify(tmsTestCaseRepository, never()).save(any()); + } + + @Test + void applyGeneration_WithoutAiSignal_ShouldLeaveOriginManualAndStillApplyWrites() { + var rq = TmsTestCaseGenerationRQ.builder().build(); + + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) + .thenReturn(testCaseExecution); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) + .thenReturn(testCaseRS); + + sut.applyGeneration(projectId, testCaseId, rq); + + assertEquals(TmsTestCaseOrigin.MANUAL, testCase.getOrigin()); + assertEquals(TmsTestCaseStatus.READY, testCase.getStatus()); + verify(tmsTestCaseQualityService).applyQualityScores(projectId, testCaseVersion, null); + verify(tmsTestCaseQualityService).applyGenerationMetadata(testCaseVersion, null); + verify(tmsTestCaseRepository, never()).save(any()); + } + @Test void create_WithTestFolder_ShouldCreateAndReturnTestCase() { when(membershipDetails.getProjectId()).thenReturn(projectId); @@ -428,7 +528,7 @@ void create_WithTestFolder_ShouldCreateAndReturnTestCase() { .thenReturn(testCase); when(tmsTestCaseVersionService.createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); var result = sut.create(membershipDetails, user, testCaseRQ); @@ -440,7 +540,7 @@ void create_WithTestFolder_ShouldCreateAndReturnTestCase() { verify(tmsTestCaseAttributeService).createTestCaseAttributes(projectId, testCase, attributes); verify(tmsTestCaseVersionService).createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); } @Test @@ -459,7 +559,7 @@ void create_WithTestFolderId_ShouldCreateAndReturnTestCase() { .thenReturn(testCase); when(tmsTestCaseVersionService.createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); var result = sut.create(membershipDetails, user, testCaseWithFolderIdRQ); @@ -471,7 +571,43 @@ void create_WithTestFolderId_ShouldCreateAndReturnTestCase() { verify(tmsTestCaseAttributeService).createTestCaseAttributes(projectId, testCase, attributes); verify(tmsTestCaseVersionService).createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); + } + + @Test + void create_WithoutExplicitStatus_ShouldDefaultToReady() { + when(membershipDetails.getProjectId()).thenReturn(projectId); + when(tmsTestFolderService.create(eq(projectId), any(NewTestFolderRQ.class))) + .thenReturn(testFolderRS); + when(tmsTestCaseMapper.convertFromRQ(projectId, testCaseRQ, testFolderId)) + .thenReturn(testCase); + when(tmsTestCaseVersionService.createDefaultTestCaseVersion( + projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); + + sut.create(membershipDetails, user, testCaseRQ); + + assertEquals(TmsTestCaseStatus.READY, testCase.getStatus()); + } + + @Test + void create_WithExplicitStatus_ShouldHonorExplicitStatus() { + // A manual create still lets the caller override the READY default explicitly, e.g. to + // create a manual draft. + testCaseRQ.setStatus(com.epam.reportportal.base.core.tms.dto.TmsTestCaseStatus.DRAFT); + when(membershipDetails.getProjectId()).thenReturn(projectId); + when(tmsTestFolderService.create(eq(projectId), any(NewTestFolderRQ.class))) + .thenReturn(testFolderRS); + when(tmsTestCaseMapper.convertFromRQ(projectId, testCaseRQ, testFolderId)) + .thenReturn(testCase); + when(tmsTestCaseVersionService.createDefaultTestCaseVersion( + projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); + + sut.create(membershipDetails, user, testCaseRQ); + + assertEquals(TmsTestCaseOrigin.MANUAL, testCase.getOrigin()); + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); } @Test @@ -491,7 +627,7 @@ void create_WithStepsManualScenario_ShouldCreateAndReturnTestCase() { .thenReturn(testCase); when(tmsTestCaseVersionService.createDefaultTestCaseVersion( projectId, testCase, stepsManualScenarioRQ)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); var result = sut.create(membershipDetails, user, testCaseWithStepsRQ); @@ -503,7 +639,7 @@ void create_WithStepsManualScenario_ShouldCreateAndReturnTestCase() { verify(tmsTestCaseAttributeService).createTestCaseAttributes(projectId, testCase, attributes); verify(tmsTestCaseVersionService).createDefaultTestCaseVersion( projectId, testCase, stepsManualScenarioRQ); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); } @Test @@ -530,7 +666,7 @@ void create_WithNewTestFolder_ShouldCreateFolderAndTestCase() { .thenReturn(testCase); when(tmsTestCaseVersionService.createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); var result = sut.create(membershipDetails, user, testCaseRQWithNewFolder); @@ -539,7 +675,7 @@ void create_WithNewTestFolder_ShouldCreateFolderAndTestCase() { verify(tmsTestCaseMapper).convertFromRQ(projectId, testCaseRQWithNewFolder, newFolderId); verify(tmsTestCaseVersionService).createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); } @Test @@ -574,7 +710,7 @@ void create_WithoutTags_ShouldNotCreateAttributes() { .thenReturn(testCase); when(tmsTestCaseVersionService.createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); var result = sut.create(membershipDetails, user, testCaseRQWithoutTags); @@ -584,7 +720,7 @@ void create_WithoutTags_ShouldNotCreateAttributes() { .createTestCaseAttributes(eq(projectId), any(), any()); verify(tmsTestCaseVersionService).createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); } @Test @@ -602,7 +738,7 @@ void update_WhenTestCaseExists_ShouldUpdateAndReturnTestCase() { projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) .thenReturn(testCaseExecution); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) .thenReturn(testCaseRS); var result = sut.update(membershipDetails, user, testCaseId, testCaseRQ); @@ -617,7 +753,7 @@ void update_WhenTestCaseExists_ShouldUpdateAndReturnTestCase() { verify(tmsTestCaseVersionService).updateDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); verify(tmsTestCaseExecutionService).getLastTestCaseExecution(testCaseId); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution, null); } @Test @@ -632,7 +768,7 @@ void update_WhenTestCaseDoesNotExist_ShouldCreateNewTestCase() { .thenReturn(testCase); when(tmsTestCaseVersionService.createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, null)).thenReturn(testCaseRS); var result = sut.update(membershipDetails, user, testCaseId, testCaseRQ); @@ -645,7 +781,7 @@ void update_WhenTestCaseDoesNotExist_ShouldCreateNewTestCase() { verify(tmsTestCaseAttributeService).createTestCaseAttributes(projectId, testCase, attributes); verify(tmsTestCaseVersionService).createDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, null); } @Test @@ -669,7 +805,7 @@ void update_WithTestFolderId_ShouldUpdateAndReturnTestCase() { projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) .thenReturn(testCaseExecution); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) .thenReturn(testCaseRS); var result = sut.update(membershipDetails, user, testCaseId, testCaseWithFolderIdRQ); @@ -684,7 +820,7 @@ void update_WithTestFolderId_ShouldUpdateAndReturnTestCase() { verify(tmsTestCaseVersionService).updateDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); verify(tmsTestCaseExecutionService).getLastTestCaseExecution(testCaseId); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution, null); } @Test @@ -722,7 +858,7 @@ void patch_WhenTestCaseExists_ShouldPatchAndReturnTestCase() { projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) .thenReturn(testCaseExecution); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) .thenReturn(testCaseRS); var result = sut.patch(membershipDetails, user, testCaseId, testCaseRQ); @@ -737,7 +873,57 @@ void patch_WhenTestCaseExists_ShouldPatchAndReturnTestCase() { verify(tmsTestCaseVersionService).patchDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); verify(tmsTestCaseExecutionService).getLastTestCaseExecution(testCaseId); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution, null); + } + + @Test + void patch_ShouldNeverTouchOriginOrStatus() { + when(membershipDetails.getProjectId()).thenReturn(projectId); + + var convertedTestCase = new TmsTestCase(); + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestFolderService.create(eq(projectId), any(NewTestFolderRQ.class))) + .thenReturn(testFolderRS); + when(tmsTestCaseMapper.convertFromPatchRQ(projectId, testCaseRQ, testFolderId)) + .thenReturn(convertedTestCase); + when(tmsTestCaseVersionService.patchDefaultTestCaseVersion( + projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); + when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) + .thenReturn(testCaseExecution); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) + .thenReturn(testCaseRS); + + sut.patch(membershipDetails, user, testCaseId, testCaseRQ); + + assertEquals(TmsTestCaseOrigin.MANUAL, testCase.getOrigin()); + assertEquals(TmsTestCaseStatus.READY, testCase.getStatus()); + } + + @Test + void patch_OnAlreadyAiCase_ShouldLeaveOriginAndStatusUnchanged() { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.READY); + when(membershipDetails.getProjectId()).thenReturn(projectId); + + var convertedTestCase = new TmsTestCase(); + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestFolderService.create(eq(projectId), any(NewTestFolderRQ.class))) + .thenReturn(testFolderRS); + when(tmsTestCaseMapper.convertFromPatchRQ(projectId, testCaseRQ, testFolderId)) + .thenReturn(convertedTestCase); + when(tmsTestCaseVersionService.patchDefaultTestCaseVersion( + projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); + when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) + .thenReturn(testCaseExecution); + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) + .thenReturn(testCaseRS); + + sut.patch(membershipDetails, user, testCaseId, testCaseRQ); + + assertEquals(TmsTestCaseOrigin.AI, testCase.getOrigin()); + assertEquals(TmsTestCaseStatus.READY, testCase.getStatus()); } @Test @@ -761,7 +947,7 @@ void patch_WithTestFolderId_ShouldPatchAndReturnTestCase() { projectId, testCase, textManualScenarioRQ)).thenReturn(testCaseVersion); when(tmsTestCaseExecutionService.getLastTestCaseExecution(testCaseId)) .thenReturn(testCaseExecution); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) .thenReturn(testCaseRS); var result = sut.patch(membershipDetails, user, testCaseId, testCaseWithFolderIdRQ); @@ -776,7 +962,7 @@ void patch_WithTestFolderId_ShouldPatchAndReturnTestCase() { verify(tmsTestCaseVersionService).patchDefaultTestCaseVersion( projectId, testCase, textManualScenarioRQ); verify(tmsTestCaseExecutionService).getLastTestCaseExecution(testCaseId); - verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution); + verify(tmsTestCaseMapper).convert(testCase, testCaseVersion, testCaseExecution, null); } @Test @@ -1474,7 +1660,7 @@ void exportToFile_WithSpecificIds_ShouldExportSpecificTestCases() { when(tmsTestCaseVersionService.getDefaultVersion(2L)).thenReturn(testCaseVersion); when(tmsTestCaseExecutionService.getLastTestCaseExecution(1L)).thenReturn(testCaseExecution); when(tmsTestCaseExecutionService.getLastTestCaseExecution(2L)).thenReturn(testCaseExecution); - when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution)) + when(tmsTestCaseMapper.convert(testCase, testCaseVersion, testCaseExecution, null)) .thenReturn(testCaseRS); when(exporterFactory.getExporter(format)).thenReturn(exporter); @@ -1536,7 +1722,7 @@ void getTestCasesByCriteria_WithContent_ShouldReturnPagedResults() { when(tmsTestCaseRepository.findByProjectIdAndIds(projectId, testCaseIds)).thenReturn(testCases); when(tmsTestCaseExecutionService.getLastTestCasesExecutionsByTestCaseIds(testCaseIds)) .thenReturn(lastExecutions); - when(tmsTestCaseMapper.convert(testCases, defaultVersions, lastExecutions, pageable, 1L)) + when(tmsTestCaseMapper.convert(testCases, defaultVersions, lastExecutions, Map.of(), pageable, 1L)) .thenReturn(convertedPage); var result = sut.getTestCasesByCriteria(projectId, filter, pageable); @@ -1549,7 +1735,7 @@ void getTestCasesByCriteria_WithContent_ShouldReturnPagedResults() { verify(tmsTestCaseVersionService).getDefaultVersions(testCaseIds); verify(tmsTestCaseRepository).findByProjectIdAndIds(projectId, testCaseIds); verify(tmsTestCaseExecutionService).getLastTestCasesExecutionsByTestCaseIds(testCaseIds); - verify(tmsTestCaseMapper).convert(testCases, defaultVersions, lastExecutions, pageable, 1L); + verify(tmsTestCaseMapper).convert(testCases, defaultVersions, lastExecutions, Map.of(), pageable, 1L); } @Test @@ -1572,7 +1758,7 @@ void getTestCasesByCriteria_WithNoContent_ShouldReturnEmptyPage() { verify(tmsTestCaseRepository, never()).findByProjectIdAndIds(any(Long.class), any()); verify(tmsTestCaseExecutionService, never()).getLastTestCasesExecutionsByTestCaseIds(any()); verify(tmsTestCaseMapper, never()) - .convert(any(List.class), any(Map.class), any(Map.class), any(), any(Long.class)); + .convert(any(List.class), any(Map.class), any(Map.class), any(), any(), any(Long.class)); } @Test @@ -1591,7 +1777,7 @@ void getTestCasesByCriteria_WithNullFilter_ShouldReturnPagedResults() { when(tmsTestCaseRepository.findByProjectIdAndIds(projectId, testCaseIds)).thenReturn(testCases); when(tmsTestCaseExecutionService.getLastTestCasesExecutionsByTestCaseIds(testCaseIds)) .thenReturn(lastExecutions); - when(tmsTestCaseMapper.convert(testCases, defaultVersions, lastExecutions, pageable, 1L)) + when(tmsTestCaseMapper.convert(testCases, defaultVersions, lastExecutions, Map.of(), pageable, 1L)) .thenReturn(convertedPage); var result = sut.getTestCasesByCriteria(projectId, null, pageable); @@ -1629,7 +1815,7 @@ void getTestCasesByCriteria_WithMultipleTestCases_ShouldReturnOrderedResults() { when(tmsTestCaseExecutionService.getLastTestCasesExecutionsByTestCaseIds(testCaseIds)) .thenReturn(lastExecutions); when(tmsTestCaseMapper.convert( - any(List.class), eq(defaultVersions), eq(lastExecutions), eq(pageable), eq(3L))) + any(List.class), eq(defaultVersions), eq(lastExecutions), any(), eq(pageable), eq(3L))) .thenReturn(convertedPage); var result = sut.getTestCasesByCriteria(projectId, filter, pageable); @@ -1641,7 +1827,7 @@ void getTestCasesByCriteria_WithMultipleTestCases_ShouldReturnOrderedResults() { verify(tmsTestCaseRepository).findByProjectIdAndIds(projectId, testCaseIds); verify(tmsTestCaseExecutionService).getLastTestCasesExecutionsByTestCaseIds(testCaseIds); verify(tmsTestCaseMapper).convert( - any(List.class), eq(defaultVersions), eq(lastExecutions), eq(pageable), eq(3L)); + any(List.class), eq(defaultVersions), eq(lastExecutions), any(), eq(pageable), eq(3L)); } @Test @@ -1968,7 +2154,7 @@ void duplicate_WithMembershipDetailsAndUser_ShouldPublishCreatedEvent() { when(tmsTestCaseRepository.save(duplicatedTestCase)).thenReturn(duplicatedTestCase); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase, originalVersion)) .thenReturn(duplicatedVersion); - when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion)) + when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion, null)) .thenReturn(duplicatedTestCaseRS); var result = sut.duplicate(membershipDetails, user, duplicateRequest); @@ -2019,7 +2205,7 @@ void duplicate_WithValidTestCaseIds_ShouldDuplicateTestCases() { when(tmsTestCaseRepository.save(duplicatedTestCase1)).thenReturn(duplicatedTestCase1); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase1, originalVersion1)) .thenReturn(duplicatedVersion1); - when(tmsTestCaseMapper.convert(duplicatedTestCase1, duplicatedVersion1)) + when(tmsTestCaseMapper.convert(duplicatedTestCase1, duplicatedVersion1, null)) .thenReturn(duplicatedTestCaseRS1); var originalTestCase2 = new TmsTestCase(); @@ -2042,7 +2228,7 @@ void duplicate_WithValidTestCaseIds_ShouldDuplicateTestCases() { when(tmsTestCaseRepository.save(duplicatedTestCase2)).thenReturn(duplicatedTestCase2); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase2, originalVersion2)) .thenReturn(duplicatedVersion2); - when(tmsTestCaseMapper.convert(duplicatedTestCase2, duplicatedVersion2)) + when(tmsTestCaseMapper.convert(duplicatedTestCase2, duplicatedVersion2, null)) .thenReturn(duplicatedTestCaseRS2); var result = sut.duplicate(membershipDetails, user, duplicateRequest); @@ -2101,7 +2287,7 @@ void duplicate_WithTestFolder_ShouldDuplicateTestCases() { when(tmsTestCaseRepository.save(duplicatedTestCase)).thenReturn(duplicatedTestCase); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase, originalVersion)) .thenReturn(duplicatedVersion); - when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion)) + when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion, null)) .thenReturn(duplicatedTestCaseRS); var result = sut.duplicate(membershipDetails, user, duplicateRequest); @@ -2151,7 +2337,7 @@ void duplicate_WithEmptyTagsTaggedTestCase_ShouldDuplicateTestCaseWithoutEmptyTa when(tmsTestCaseRepository.save(duplicatedTestCase)).thenReturn(duplicatedTestCase); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase, originalVersion)) .thenReturn(duplicatedVersion); - when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion)) + when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion, null)) .thenReturn(duplicatedTestCaseRS); var result = sut.duplicate(membershipDetails, user, duplicateRequest); @@ -2254,7 +2440,7 @@ void duplicateTestCase_WithUniqueNameGeneration_ShouldGenerateUniqueName() { when(tmsTestCaseRepository.save(duplicatedTestCase)).thenReturn(duplicatedTestCase); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase, originalVersion)) .thenReturn(duplicatedVersion); - when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion, null)).thenReturn(testCaseRS); var result = sut.duplicateTestCase(membershipDetails, user, projectId, 1L, testFolderId); @@ -2284,7 +2470,7 @@ void duplicateTestCase_WithExistingName_ShouldGenerateIncrementalName() { when(tmsTestCaseRepository.save(duplicatedTestCase)).thenReturn(duplicatedTestCase); when(tmsTestCaseVersionService.duplicateDefaultVersion(duplicatedTestCase, originalVersion)) .thenReturn(duplicatedVersion); - when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion)).thenReturn(testCaseRS); + when(tmsTestCaseMapper.convert(duplicatedTestCase, duplicatedVersion, null)).thenReturn(testCaseRS); var result = sut.duplicateTestCase(membershipDetails, user, projectId, 1L, testFolderId); @@ -2448,6 +2634,23 @@ void existsById_WhenTestCaseDoesNotExist_ShouldReturnFalse() { verify(tmsTestCaseRepository).existsByIdAndProjectId(testCaseId, projectId); } + @Test + void getReadyTestCaseIds_WithNullOrEmptyList_ShouldReturnEmptyWithoutQuerying() { + assertTrue(sut.getReadyTestCaseIds(projectId, null).isEmpty()); + assertTrue(sut.getReadyTestCaseIds(projectId, Collections.emptyList()).isEmpty()); + + verify(tmsTestCaseRepository, never()).findIdsByProjectIdAndIdsAndStatus(any(), any(), any()); + } + + @Test + void getReadyTestCaseIds_ShouldReturnOnlyReadyIdsFromRepository() { + var testCaseIds = Arrays.asList(1L, 2L, 3L); + when(tmsTestCaseRepository.findIdsByProjectIdAndIdsAndStatus(projectId, testCaseIds, TmsTestCaseStatus.READY)) + .thenReturn(Arrays.asList(1L, 3L)); + + assertEquals(Arrays.asList(1L, 3L), sut.getReadyTestCaseIds(projectId, testCaseIds)); + } + @Test void getExistingTestCaseIds_WithNullList_ShouldReturnEmptyList() { var result = sut.getExistingTestCaseIds(projectId, null); @@ -3323,4 +3526,178 @@ void getTestCaseInTestPlan_WhenTestCaseNotInPlan_ShouldThrowException() { verify(tmsTestPlanTestCaseRepository).findTestCaseIdsByTestPlanId(testPlanId); verifyNoInteractions(tmsTestCaseExecutionService); } + + @Test + void applyAutoReady_WhenScoreMeetsThreshold_ShouldSetReady() { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.DRAFT); + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseQualityService.buildMetrics(testCaseVersion)) + .thenReturn(TmsTestCaseMetricsRS.builder().overallScore(90).obsolete(false).build()); + + sut.applyAutoReady(projectId, testCaseId, 80); + + assertEquals(TmsTestCaseStatus.READY, testCase.getStatus()); + verify(tmsTestCaseRepository).save(testCase); + } + + @Test + void applyAutoReady_WhenScoreBelowThreshold_ShouldStayDraftAndNotSave() { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.DRAFT); + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseQualityService.buildMetrics(testCaseVersion)) + .thenReturn(TmsTestCaseMetricsRS.builder().overallScore(60).obsolete(false).build()); + + sut.applyAutoReady(projectId, testCaseId, 80); + + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); + verify(tmsTestCaseRepository, never()).save(any()); + } + + @Test + void applyAutoReady_WhenNoMetrics_ShouldNotChangeStatus() { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.DRAFT); + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseQualityService.buildMetrics(testCaseVersion)).thenReturn(null); + + sut.applyAutoReady(projectId, testCaseId, 80); + + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); + verify(tmsTestCaseRepository, never()).save(any()); + } + + @Test + void applyAutoReady_WhenScoreObsolete_ShouldNotChangeStatus() { + testCase.setOrigin(TmsTestCaseOrigin.AI); + testCase.setStatus(TmsTestCaseStatus.DRAFT); + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.of(testCase)); + when(tmsTestCaseVersionService.getDefaultVersion(testCaseId)).thenReturn(testCaseVersion); + when(tmsTestCaseQualityService.buildMetrics(testCaseVersion)) + .thenReturn(TmsTestCaseMetricsRS.builder().overallScore(95).obsolete(true).build()); + + sut.applyAutoReady(projectId, testCaseId, 80); + + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); + verify(tmsTestCaseRepository, never()).save(any()); + } + + @Test + void applyAutoReady_WhenTestCaseNotFound_ShouldDoNothing() { + when(tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId)) + .thenReturn(Optional.empty()); + + sut.applyAutoReady(projectId, testCaseId, 80); + + verifyNoInteractions(tmsTestCaseVersionService); + verify(tmsTestCaseQualityService, never()).buildMetrics(any()); + verify(tmsTestCaseRepository, never()).save(any()); + } + + @Test + void applyAutoReadyBatch_WithEmptyCollection_ShouldDoNothing() { + sut.applyAutoReadyBatch(projectId, List.of(), 80); + + verifyNoInteractions(tmsTestCaseVersionService); + verify(tmsTestCaseQualityService, never()).buildMetricsBatch(any()); + verify(tmsTestCaseRepository, never()).saveAll(any()); + } + + @Test + void applyAutoReadyBatch_WithMixedScores_ShouldOnlyPromoteQualifyingAiDrafts() { + var readyCandidate = new TmsTestCase(); + readyCandidate.setId(10L); + readyCandidate.setOrigin(TmsTestCaseOrigin.AI); + readyCandidate.setStatus(TmsTestCaseStatus.DRAFT); + var draftCandidate = new TmsTestCase(); + draftCandidate.setId(11L); + draftCandidate.setOrigin(TmsTestCaseOrigin.AI); + draftCandidate.setStatus(TmsTestCaseStatus.DRAFT); + var obsoleteCandidate = new TmsTestCase(); + obsoleteCandidate.setId(12L); + obsoleteCandidate.setOrigin(TmsTestCaseOrigin.AI); + obsoleteCandidate.setStatus(TmsTestCaseStatus.DRAFT); + var noVersionCandidate = new TmsTestCase(); + noVersionCandidate.setId(13L); + noVersionCandidate.setOrigin(TmsTestCaseOrigin.AI); + noVersionCandidate.setStatus(TmsTestCaseStatus.DRAFT); + + var readyVersion = new TmsTestCaseVersion(); + readyVersion.setId(100L); + var draftVersion = new TmsTestCaseVersion(); + draftVersion.setId(101L); + var obsoleteVersion = new TmsTestCaseVersion(); + obsoleteVersion.setId(102L); + + var testCases = List.of(readyCandidate, draftCandidate, obsoleteCandidate, noVersionCandidate); + + when(tmsTestCaseVersionService.getDefaultVersions(List.of(10L, 11L, 12L, 13L))) + .thenReturn(Map.of(10L, readyVersion, 11L, draftVersion, 12L, obsoleteVersion)); + when(tmsTestCaseQualityService.buildMetricsBatch(any())).thenReturn(Map.of( + 100L, TmsTestCaseMetricsRS.builder().overallScore(90).obsolete(false).build(), + 101L, TmsTestCaseMetricsRS.builder().overallScore(60).obsolete(false).build(), + 102L, TmsTestCaseMetricsRS.builder().overallScore(95).obsolete(true).build())); + + sut.applyAutoReadyBatch(projectId, testCases, 80); + + assertEquals(TmsTestCaseStatus.READY, readyCandidate.getStatus()); + assertEquals(TmsTestCaseStatus.DRAFT, draftCandidate.getStatus()); + assertEquals(TmsTestCaseStatus.DRAFT, obsoleteCandidate.getStatus()); + assertEquals(TmsTestCaseStatus.DRAFT, noVersionCandidate.getStatus()); + + verify(tmsTestCaseRepository).saveAll(argThat((List saved) -> + saved.size() == 1 && saved.contains(readyCandidate))); + } + + @Test + void applyAutoReadyBatch_WithManualOrAlreadyReadyCases_ShouldNeverTouchThem() { + var manualDraft = new TmsTestCase(); + manualDraft.setId(20L); + manualDraft.setOrigin(TmsTestCaseOrigin.MANUAL); + manualDraft.setStatus(TmsTestCaseStatus.DRAFT); + var aiReady = new TmsTestCase(); + aiReady.setId(21L); + aiReady.setOrigin(TmsTestCaseOrigin.AI); + aiReady.setStatus(TmsTestCaseStatus.READY); + + sut.applyAutoReadyBatch(projectId, List.of(manualDraft, aiReady), 80); + + assertEquals(TmsTestCaseStatus.DRAFT, manualDraft.getStatus()); + assertEquals(TmsTestCaseStatus.READY, aiReady.getStatus()); + verifyNoInteractions(tmsTestCaseVersionService); + verify(tmsTestCaseRepository, never()).saveAll(any()); + } + + @Test + void applyAutoReadyBatch_WhenNoneQualify_ShouldNotCallSaveAll() { + var candidate = new TmsTestCase(); + candidate.setId(10L); + candidate.setOrigin(TmsTestCaseOrigin.AI); + candidate.setStatus(TmsTestCaseStatus.DRAFT); + + when(tmsTestCaseVersionService.getDefaultVersions(List.of(10L))).thenReturn(Map.of()); + + sut.applyAutoReadyBatch(projectId, List.of(candidate), 80); + + verify(tmsTestCaseRepository, never()).saveAll(any()); + } + + @Test + void getEntitiesByDisplayIds_ShouldDelegateToRepository() { + var testCases = List.of(testCase); + when(tmsTestCaseRepository.findByProjectIdAndDisplayIdIn(projectId, List.of("TC-1", "TC-2"))) + .thenReturn(testCases); + + var result = sut.getEntitiesByDisplayIds(projectId, List.of("TC-1", "TC-2")); + + assertEquals(testCases, result); + } } diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImplTest.java index abf764e8e2..de8589d174 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImplTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestPlanServiceImplTest.java @@ -368,6 +368,7 @@ void shouldAddTestCasesToPlanSuccessfully() { when(testPlanRepository.existsByIdAndProject_Id(testPlanId, projectId)).thenReturn(true); when(tmsTestCaseService.getExistingTestCaseIds(projectId, testCaseIds)).thenReturn(List.of(10L, 20L, 30L)); + when(tmsTestCaseService.getReadyTestCaseIds(projectId, testCaseIds)).thenReturn(List.of(10L, 20L, 30L)); when(tmsTestPlanTestCaseRepository.findTestCaseIdsByTestPlanId(testPlanId)).thenReturn(List.of(10L)); when(tmsTestPlanTestCaseRepository.insertTestPlanTestCaseIgnoreConflict(testPlanId, 20L)).thenReturn(1); when(tmsTestPlanTestCaseRepository.insertTestPlanTestCaseIgnoreConflict(testPlanId, 30L)).thenReturn(1); @@ -392,6 +393,7 @@ void shouldAddTestCasesToPlanWithErrors() { when(testPlanRepository.existsByIdAndProject_Id(testPlanId, projectId)).thenReturn(true); when(tmsTestCaseService.getExistingTestCaseIds(projectId, testCaseIds)).thenReturn(List.of(10L)); + when(tmsTestCaseService.getReadyTestCaseIds(projectId, testCaseIds)).thenReturn(List.of(10L)); when(tmsTestPlanTestCaseRepository.findTestCaseIdsByTestPlanId(testPlanId)).thenReturn(List.of()); when(tmsTestPlanTestCaseRepository.insertTestPlanTestCaseIgnoreConflict(testPlanId, 10L)).thenReturn(1); when(tmsTestPlanMapper.convertToRS(anyInt(), anyInt(), anyList())).thenReturn(expectedResult); @@ -402,6 +404,33 @@ void shouldAddTestCasesToPlanWithErrors() { verify(tmsTestPlanMapper).convertToRS(eq(2), eq(1), anyList()); } + @Test + void shouldRejectNotReadyTestCasesWithNotReadyReasonAndAddTheRest() { + var projectId = 1L; + var testPlanId = 2L; + var testCaseIds = List.of(10L, 20L); + + when(testPlanRepository.existsByIdAndProject_Id(testPlanId, projectId)).thenReturn(true); + when(tmsTestCaseService.getExistingTestCaseIds(projectId, testCaseIds)).thenReturn(List.of(10L, 20L)); + when(tmsTestCaseService.getReadyTestCaseIds(projectId, testCaseIds)).thenReturn(List.of(10L)); + when(tmsTestPlanTestCaseRepository.findTestCaseIdsByTestPlanId(testPlanId)).thenReturn(List.of()); + when(tmsTestPlanTestCaseRepository.insertTestPlanTestCaseIgnoreConflict(testPlanId, 10L)).thenReturn(1); + when(tmsTestPlanMapper.convertToRS(anyInt(), anyInt(), anyList())) + .thenReturn(BatchTestCaseOperationResultRS.builder().build()); + + sut.addTestCasesToPlan(projectId, testPlanId, testCaseIds); + + var errors = org.mockito.ArgumentCaptor.forClass(List.class); + verify(tmsTestPlanMapper).convertToRS(eq(2), eq(1), errors.capture()); + var reported = (List) errors.getValue(); + assertEquals(1, reported.size()); + assertEquals(20L, reported.get(0).getTestCaseId()); + assertEquals( + com.epam.reportportal.base.core.tms.dto.batch.BatchTestCaseOperationErrorReason.NOT_READY, + reported.get(0).getReason()); + verify(tmsTestPlanTestCaseRepository, never()).insertTestPlanTestCaseIgnoreConflict(testPlanId, 20L); + } + @Test void shouldThrowNotFoundWhenAddTestCasesToNonExistentPlan() { var projectId = 1L; @@ -591,6 +620,7 @@ void shouldDuplicateTestPlanSuccessfully() { when(tmsTestPlanTestCaseRepository.findTestCaseIdsByTestPlanId(testPlanId)).thenReturn(originalTestCaseIds); when(tmsTestCaseService.duplicateTestCases(membershipDetails, user, originalTestCaseIds)).thenReturn(duplicationResult); when(tmsTestCaseService.getExistingTestCaseIds(projectId, duplicatedTestCaseIds)).thenReturn(duplicatedTestCaseIds); + when(tmsTestCaseService.getReadyTestCaseIds(projectId, duplicatedTestCaseIds)).thenReturn(duplicatedTestCaseIds); when(tmsTestPlanTestCaseRepository.findTestCaseIdsByTestPlanId(3L)).thenReturn(List.of()); when(tmsTestPlanTestCaseRepository.insertTestPlanTestCaseIgnoreConflict(eq(3L), anyLong())).thenReturn(1); when(testPlanRepository.existsByIdAndProject_Id(3L, projectId)).thenReturn(true); diff --git a/src/test/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidatorTest.java b/src/test/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidatorTest.java new file mode 100644 index 0000000000..aea596e63b --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidatorTest.java @@ -0,0 +1,97 @@ +package com.epam.reportportal.base.core.tms.validation; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardCriterionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsQualityStandardRQ; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Covers {@code sum(criteria[].maxPoints) == 100} enforced by {@link ValidQualityStandardRQ}. + */ +class QualityStandardRQValidatorTest { + + private Validator validator; + + @BeforeEach + void setUp() { + var factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + private TmsQualityStandardCriterionRQ criterion(int maxPoints) { + return TmsQualityStandardCriterionRQ.builder() + .name("criterion") + .maxPoints(maxPoints) + .sequence(1) + .build(); + } + + @Test + void shouldPassValidationWhenCriteriaMaxPointsSumToExactlyOneHundred() { + var rq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of(criterion(25), criterion(20), criterion(20), criterion(15), criterion(10), criterion(10))) + .build(); + + var violations = validator.validate(rq); + + assertTrue(violations.isEmpty()); + } + + @Test + void shouldFailValidationWhenCriteriaMaxPointsSumBelowOneHundred() { + var rq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of(criterion(25), criterion(20))) + .build(); + + var violations = validator.validate(rq); + + assertFalse(violations.isEmpty()); + } + + @Test + void shouldFailValidationWhenCriteriaMaxPointsSumAboveOneHundred() { + var rq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of(criterion(60), criterion(60))) + .build(); + + var violations = validator.validate(rq); + + assertFalse(violations.isEmpty()); + } + + @Test + void shouldFailValidationWhenAnyCriterionMaxPointsExceedsOneHundred() { + // A single criterion above 100 is rejected by @Max on the field itself, independent of + // the class-level sum check - this is what keeps the sum computation (a long, see + // QualityStandardRQValidator) from ever needing to reason about implausibly large inputs. + var rq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of(criterion(101))) + .build(); + + var violations = validator.validate(rq); + + assertFalse(violations.isEmpty()); + } + + @Test + void shouldFailValidationWhenCriteriaIsEmpty() { + var rq = TmsQualityStandardRQ.builder() + .name("Default TC quality rubric") + .criteria(List.of()) + .build(); + + var violations = validator.validate(rq); + + assertFalse(violations.isEmpty()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterConditionTest.java b/src/test/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterConditionTest.java index 7ac81015c9..acc7a09908 100644 --- a/src/test/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterConditionTest.java +++ b/src/test/java/com/epam/reportportal/base/infrastructure/persistence/commons/querygen/FilterConditionTest.java @@ -2,6 +2,8 @@ import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.TestItemCriteriaConstant.CRITERIA_TEST_CASE_ID; import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_DISPLAY_ID; +import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_ORIGIN; +import static com.epam.reportportal.base.infrastructure.persistence.commons.querygen.constant.tms.TmsTestCaseCriteriaConstant.CRITERIA_TMS_TEST_CASE_STATUS; import static com.epam.reportportal.base.infrastructure.persistence.jooq.Tables.TMS_TEST_CASE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -54,6 +56,28 @@ void tmsTestCaseDisplayIdIsSupported() { assertEquals(TMS_TEST_CASE.DISPLAY_ID.getQualifiedName().toString(), criteriaHolder.getQueryCriteria()); } + @Test + void tmsTestCaseStatusIsSupported() { + CriteriaHolder criteriaHolder = FilterTarget.TMS_TEST_CASE_TARGET + .getCriteriaByFilter(CRITERIA_TMS_TEST_CASE_STATUS) + .orElseThrow(); + + assertEquals(CRITERIA_TMS_TEST_CASE_STATUS, criteriaHolder.getFilterCriteria()); + assertEquals(String.class, criteriaHolder.getDataType()); + assertEquals(TMS_TEST_CASE.STATUS.getQualifiedName().toString(), criteriaHolder.getQueryCriteria()); + } + + @Test + void tmsTestCaseOriginIsSupported() { + CriteriaHolder criteriaHolder = FilterTarget.TMS_TEST_CASE_TARGET + .getCriteriaByFilter(CRITERIA_TMS_TEST_CASE_ORIGIN) + .orElseThrow(); + + assertEquals(CRITERIA_TMS_TEST_CASE_ORIGIN, criteriaHolder.getFilterCriteria()); + assertEquals(String.class, criteriaHolder.getDataType()); + assertEquals(TMS_TEST_CASE.ORIGIN.getQualifiedName().toString(), criteriaHolder.getQueryCriteria()); + } + @Test void testCaseIdAllowsOnlyEqualsConditions() { CriteriaHolder criteriaHolder = FilterTarget.TEST_ITEM_TARGET diff --git a/src/test/java/com/epam/reportportal/base/infrastructure/persistence/dao/IntegrationTypeRepositoryTest.java b/src/test/java/com/epam/reportportal/base/infrastructure/persistence/dao/IntegrationTypeRepositoryTest.java index 9c3fdc7e7a..462b0500f8 100644 --- a/src/test/java/com/epam/reportportal/base/infrastructure/persistence/dao/IntegrationTypeRepositoryTest.java +++ b/src/test/java/com/epam/reportportal/base/infrastructure/persistence/dao/IntegrationTypeRepositoryTest.java @@ -80,6 +80,7 @@ void shouldFindAllIntegrationTypesByPluginType() { List integrationTypes = integrationTypeRepository.findAllByPluginType( PluginTypeEnum.BUILT_IN); assertNotNull(integrationTypes); - assertEquals(2, integrationTypes.size()); + // 'github-actions' and 'gitlab-ci' (CI-trigger integration types, migration 226) are BUILT_IN too. + assertEquals(4, integrationTypes.size()); } } diff --git a/src/test/resources/db/aifactory/e2e/pipeline-e2e-cleanup.sql b/src/test/resources/db/aifactory/e2e/pipeline-e2e-cleanup.sql new file mode 100644 index 0000000000..3e7d5e95a9 --- /dev/null +++ b/src/test/resources/db/aifactory/e2e/pipeline-e2e-cleanup.sql @@ -0,0 +1,7 @@ +-- The e2e test commits (it has to: the Library announces a change once it is committed), so everything it +-- created is removed here. Deleting the pipeline cascades to its iterations, stages and stage/test-case links. +delete from pipeline where project_id = 1 and name = 'Test case generation'; +delete from tms_test_case where test_folder_id = 910; +delete from tms_test_folder where id = 910; +delete from tms_quality_standard where id = 910; +delete from tms_project_sequence where project_id = 1 and entity_type = 'TEST_CASE'; diff --git a/src/test/resources/db/aifactory/e2e/pipeline-e2e-fill.sql b/src/test/resources/db/aifactory/e2e/pipeline-e2e-fill.sql new file mode 100644 index 0000000000..40b104d2b0 --- /dev/null +++ b/src/test/resources/db/aifactory/e2e/pipeline-e2e-fill.sql @@ -0,0 +1,19 @@ +-- Fixture of PipelineIterationE2eIntegrationTest (project 1 = superadmin_personal): the Library folder the +-- agent uploads into, and the project's quality standard the agent reports its scores against. +-- The pipeline itself is not seeded: the first report of the scenario creates it. +insert into tms_test_folder (id, "name", description, project_id) +values (910, 'iteration-1', 'tms/library/emptystate/iteration-1', 1); + +insert into tms_quality_standard (id, project_id, name, description) +values (910, 1, 'Test case quality standard', 'six criteria, sum of 100'); + +insert into tms_quality_standard_criterion (id, standard_id, name, max_points, sequence, criterion_key) +values (910, 910, 'Atomicity', 15, 1, 'atomicity'), + (911, 910, 'Clear steps', 20, 2, 'clear_steps'), + (912, 910, 'Clear expected results', 20, 3, 'clear_expected_results'), + (913, 910, 'No invented logic', 20, 4, 'no_invented_logic'), + (914, 910, 'No invented UI', 15, 5, 'no_invented_ui'), + (915, 910, 'Coherence', 10, 6, 'coherence'); + +select setval('tms_quality_standard_id_seq', (select coalesce(max(id), 1) from tms_quality_standard)); +select setval('tms_quality_standard_criterion_id_seq', (select coalesce(max(id), 1) from tms_quality_standard_criterion)); diff --git a/src/test/resources/db/aifactory/pipeline/pipeline-cleanup.sql b/src/test/resources/db/aifactory/pipeline/pipeline-cleanup.sql new file mode 100644 index 0000000000..cc7e2036c6 --- /dev/null +++ b/src/test/resources/db/aifactory/pipeline/pipeline-cleanup.sql @@ -0,0 +1,11 @@ +-- Only needed for tests that run with @Transactional(propagation = NOT_SUPPORTED) — see +-- PipelineIntegrationTest's retry tests. Those tests bypass the normal test-transaction +-- rollback (deliberately, so the retry service's own REQUIRES_NEW transactions can see the +-- fixture), so their rows must be deleted explicitly instead. Harmless no-op for every other +-- (rollback-based) test in the class, since the fixture rows are already gone by the time this +-- runs. `ON DELETE CASCADE` takes care of pipeline_iteration/pipeline_stage/pipeline_stage_test_case. +delete from pipeline where id in (900, 901, 902); +delete from integration where id = 900; +delete from tms_test_case where id in (900, 901); +delete from tms_test_folder where id = 900; +delete from tms_project_sequence where project_id = 1 and entity_type = 'TEST_CASE'; diff --git a/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql b/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql new file mode 100644 index 0000000000..bd040f7842 --- /dev/null +++ b/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql @@ -0,0 +1,102 @@ +-- Minimal TMS test folder/test case used only to exercise Pipeline's link-by-displayId +-- ingest flow. Project 1 = superadmin_personal, project 2 = default_personal (same +-- convention as src/test/resources/db/tms/tms-test-case/tms-test-case-fill.sql). +insert into tms_test_folder (id, "name", description, project_id) +values (900, 'Pipeline Folder', 'Folder for pipeline integration tests', 1); + +insert into tms_test_case (id, "name", description, test_folder_id, priority, project_id, display_id) +values (900, 'Pipeline Linked Test Case', 'Linked from a pipeline stage', 900, 'HIGH', 1, 'TC-900'); + +insert into tms_test_case (id, "name", description, test_folder_id, priority, project_id, display_id) +values (901, 'Pipeline Second Test Case', 'Another linked test case', 900, 'MEDIUM', 1, 'TC-901'); + +-- Pipeline definitions +insert into pipeline (id, project_id, name, type, description, attributes, settings, created_at) +values (900, 1, 'factory-tc-gen', 'TEST_CASE_GENERATION', 'Factory pipeline: agents convert requirements into test cases', + '[{"key": "repo", "label": "Repository", "value": "org/repo", "type": "META", "valueType": "TEXT"}]', + null, '2026-08-28 16:40:00'); + +insert into pipeline (id, project_id, name, description, created_at) +values (901, 2, 'other-project-pipeline', 'Belongs to a different project', '2026-08-28 16:40:00'); + +-- A second pipeline definition in the SAME project, purely so the "compare must be same +-- pipeline definition" guard has something legitimate to reject. +insert into pipeline (id, project_id, name, description, created_at) +values (902, 1, 'other-pipeline-same-project', 'A different pipeline definition, same project', '2026-08-28 16:40:00'); + +-- Iterations for pipeline 900 (two, so compare/list have something to work with). +-- Attributes are stored as a plain JSON array of typed items; search_text / attribute_index are the +-- values the application derives from them on write. +insert into pipeline_iteration (id, pipeline_id, iteration_number, status, status_category, attributes, search_text, attribute_index, + started_at, finished_at, rerun, created_by, created_at) +values (900, 900, 1, 'Completed', 'PASSED', + '[{"key": "requirement", "label": "Requirement", "value": "US-TMS-EXP-002", "type": "META", "valueType": "TEXT", "comparisonKey": true}, + {"key": "trigger", "label": "Trigger", "value": "CI - booking pack", "type": "META", "valueType": "TEXT"}, + {"key": "suiteScore", "label": "Suite score", "value": "86", "type": "META", "valueType": "SCORE", "direction": "HIGHER_BETTER"}, + {"key": "cost", "label": "Cost", "value": "0.61", "type": "META", "valueType": "MONEY", "direction": "LOWER_BETTER"}, + {"key": "env", "value": "beta5", "type": "TAG", "valueType": "TEXT"}, + {"key": "ci", "value": "#1284551", "type": "TAG", "valueType": "LINK", "url": "https://ci.example/runs/1284551"}]', + 'us-tms-exp-002 ci - booking pack 86 0.61 beta5 #1284551', + chr(1) || 'requirement' || chr(2) || 'us-tms-exp-002' || chr(1) || chr(1) || 'env' || chr(2) || 'beta5' || chr(1), + '2026-09-01 09:00:00', '2026-09-01 09:05:00', false, 1, '2026-09-01 09:05:00'); + +insert into pipeline_iteration (id, pipeline_id, iteration_number, status, status_category, attributes, search_text, attribute_index, + started_at, finished_at, rerun, created_by, created_at) +values (901, 900, 2, 'In review', 'WAITING', + '[{"key": "requirement", "label": "Requirement", "value": "US-TMS-EXP-002", "type": "META", "valueType": "TEXT", "comparisonKey": true}, + {"key": "trigger", "label": "Trigger", "value": "CI - booking pack retry", "type": "META", "valueType": "TEXT"}, + {"key": "suiteScore", "label": "Suite score", "value": "72", "type": "META", "valueType": "SCORE", "direction": "HIGHER_BETTER"}, + {"key": "cost", "label": "Cost", "value": "0.98", "type": "META", "valueType": "MONEY", "direction": "LOWER_BETTER"}]', + 'us-tms-exp-002 ci - booking pack retry 72 0.98', + chr(1) || 'requirement' || chr(2) || 'us-tms-exp-002' || chr(1), + '2026-09-03 14:22:00', '2026-09-03 14:26:12', false, 1, '2026-09-03 14:26:12'); + +-- Iteration for the other project's pipeline, to prove project isolation +insert into pipeline_iteration (id, pipeline_id, iteration_number, status, status_category, rerun, created_at) +values (910, 901, 1, 'Completed', 'PASSED', false, '2026-09-01 09:05:00'); + +-- Iteration for the same-project-different-pipeline definition (compare-must-match guard) +insert into pipeline_iteration (id, pipeline_id, iteration_number, status, status_category, rerun, created_at) +values (920, 902, 1, 'Completed', 'PASSED', false, '2026-09-01 09:05:00'); + +-- Stages (path = own id: these fixture rows are all top-level, no nesting) +insert into pipeline_stage (id, iteration_id, stage_key, name, sequence, path, status, status_category, attributes, sections) +values (900, 900, 'stage-gen-tc', 'MD -> Test cases', 1, '900', 'Passed', 'PASSED', + '[{"key": "cases", "label": "cases", "value": "4", "type": "META", "valueType": "NUMBER"}, + {"key": "agent", "value": "create-test-cases@0.4", "type": "TAG", "valueType": "TEXT"}]', + '[{"key": "grades", "title": "Grades", + "columns": [{"key": "case", "label": "Test case", "valueType": "TEXT"}, {"key": "score", "label": "Score", "valueType": "SCORE"}], + "rows": [{"key": "TC-900", "cells": {"case": {"value": "Pipeline Linked Test Case", "target": {"entityType": "TEST_CASE", "entityId": "TC-900"}}, + "score": {"value": "86"}}}]}]'); + +insert into pipeline_stage (id, iteration_id, stage_key, name, sequence, path, status, status_category, attributes, execution) +values (901, 901, 'stage-gen-tc', 'MD -> Test cases', 1, '901', 'Needs human', 'WAITING', + '[{"key": "agent", "value": "create-test-cases@0.4", "type": "TAG", "valueType": "TEXT"}]', + '{"executorType": "GITHUB_ACTIONS", "externalRunId": "78911", + "runUrl": "https://github.com/org/repo/actions/runs/123456", + "retryable": true, "retryParameters": {"repo": "org/repo", "workflowRef": "ai-pipeline.yml"}}'); + +insert into pipeline_stage (id, iteration_id, stage_key, name, sequence, path, status, status_category, attributes) +values (902, 901, 'stage-upload', 'Upload to Library', 2, '902', 'Passed', 'PASSED', + '[{"key": "agent", "value": "upload-tc@1.0", "type": "TAG", "valueType": "TEXT"}]'); + +-- Link stage 900 to the existing TMS test case, so the sections and Review progress have a case to point at +insert into pipeline_stage_test_case (id, stage_id, test_case_id) +values (900, 900, 900); + +-- CI-trigger integration for the GitHub Actions retry test (type 9000 seeded by V226, group AUTOMATION) +insert into integration (id, project_id, type, enabled, creator, creation_date, params, name) +values (900, 1, 9000, true, 'superadmin', now(), + '{"params": {"token": "gh-test-token", "ref": "main", "repo": "org/repo"}}', 'pipeline-github-actions'); + +select setval('pipeline_id_seq', (select coalesce(max(id), 1) from pipeline)); +select setval('pipeline_iteration_id_seq', (select coalesce(max(id), 1) from pipeline_iteration)); +select setval('pipeline_stage_id_seq', (select coalesce(max(id), 1) from pipeline_stage)); +select setval('pipeline_stage_test_case_id_seq', (select coalesce(max(id), 1) from pipeline_stage_test_case)); +select setval('tms_test_folder_id_seq', (select coalesce(max(id), 1) from tms_test_folder)); +select setval('tms_test_case_id_seq', (select coalesce(max(id), 1) from tms_test_case)); +select setval('integration_id_seq', (select coalesce(max(id), 1) from integration)); + +insert into tms_project_sequence (project_id, entity_type, current_value) +values (1, 'TEST_CASE', 901) +on conflict (project_id, entity_type) do update set current_value = excluded.current_value; diff --git a/src/test/resources/db/tms/tms-quality-standard/tms-quality-standard-fill.sql b/src/test/resources/db/tms/tms-quality-standard/tms-quality-standard-fill.sql new file mode 100644 index 0000000000..4c16bcf2ea --- /dev/null +++ b/src/test/resources/db/tms/tms-quality-standard/tms-quality-standard-fill.sql @@ -0,0 +1,14 @@ +-- Project 2 (default_personal) already has a quality standard defined, so GET/PUT/DELETE +-- have something to work with. Project 1 (superadmin_personal) deliberately has none, so +-- POST-create and the "not found" GET/PUT paths have something to exercise too. +insert into tms_quality_standard (id, project_id, name, description) +values (900, 2, 'Default TC quality rubric', '6-criteria, sum/100'); + +insert into tms_quality_standard_criterion (id, standard_id, name, max_points, sequence) +values (900, 900, 'Scenario correctness', 60, 1); + +insert into tms_quality_standard_criterion (id, standard_id, name, max_points, sequence) +values (901, 900, 'Step clarity', 40, 2); + +select setval('tms_quality_standard_id_seq', (select coalesce(max(id), 1) from tms_quality_standard)); +select setval('tms_quality_standard_criterion_id_seq', (select coalesce(max(id), 1) from tms_quality_standard_criterion));