From a1d16db2aca76f036c7253e70c394192ccc32614 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 26 Sep 2026 19:25:43 +0300 Subject: [PATCH 01/12] Pipelines implementation --- project-properties.gradle | 6 +- .../connector/AbstractCiTriggerConnector.java | 49 + .../aifactory/connector/CiHttpClient.java | 103 +++ .../connector/CiTriggerConnector.java | 38 + .../connector/GitHubActionsCiConnector.java | 81 ++ .../connector/GitLabCiConnector.java | 95 ++ .../controller/PipelineController.java | 271 ++++++ .../core/aifactory/dto/PipelineCompareRS.java | 94 ++ .../dto/PipelineIterationDetailRS.java | 46 + .../dto/PipelineIterationPatchRQ.java | 33 + .../aifactory/dto/PipelineIterationRQ.java | 50 + .../aifactory/dto/PipelineIterationRefRS.java | 18 + .../dto/PipelineIterationSummaryRS.java | 47 + .../base/core/aifactory/dto/PipelineRS.java | 34 + .../aifactory/dto/PipelineSettingsRQ.java | 30 + .../aifactory/dto/PipelineStageAttemptRS.java | 35 + .../core/aifactory/dto/PipelineStageRQ.java | 101 +++ .../core/aifactory/dto/PipelineStageRS.java | 63 ++ .../aifactory/dto/PipelineStageRetryRQ.java | 14 + .../aifactory/dto/PipelineStageRetryRS.java | 22 + .../aifactory/dto/PipelineStageSummaryRS.java | 45 + .../aifactory/dto/PipelineStepResultRQ.java | 18 + .../aifactory/dto/PipelineStepResultRS.java | 19 + .../core/aifactory/dto/PipelineUserRS.java | 19 + .../core/aifactory/dto/PipelineWarningRS.java | 37 + .../aifactory/enums/AttributeDirection.java | 14 + .../core/aifactory/enums/AttributeRollup.java | 12 + .../core/aifactory/enums/AttributeType.java | 14 + .../aifactory/enums/AttributeValueType.java | 27 + .../core/aifactory/enums/StageOrigin.java | 19 + .../core/aifactory/enums/StatusCategory.java | 105 +++ .../event/PipelineIterationIngestedEvent.java | 24 + .../PipelineStageRetryRequestedEvent.java | 27 + .../core/aifactory/mapper/PipelineMapper.java | 289 ++++++ .../aifactory/model/PipelineAttribute.java | 78 ++ .../aifactory/model/PipelineExecution.java | 62 ++ .../core/aifactory/model/PipelineLink.java | 38 + .../core/aifactory/model/PipelineSection.java | 149 +++ .../aifactory/service/IngestFingerprint.java | 45 + .../service/PipelineAttributeSupport.java | 160 ++++ .../aifactory/service/PipelineComparator.java | 206 +++++ .../service/PipelineDocumentValidator.java | 53 ++ .../service/PipelineIterationFilter.java | 20 + .../PipelineIterationIngestResult.java | 13 + .../service/PipelineIterationRefresher.java | 114 +++ .../service/PipelineIterationService.java | 44 + .../service/PipelineIterationServiceImpl.java | 526 +++++++++++ .../aifactory/service/PipelinePaging.java | 31 + .../PipelineRelatedEntityResolver.java | 34 + .../aifactory/service/PipelineService.java | 21 + .../service/PipelineServiceImpl.java | 101 +++ .../service/PipelineSettingsValidator.java | 26 + .../service/PipelineStageContributor.java | 44 + .../service/PipelineStageReportedHandler.java | 37 + .../service/PipelineStageRetryService.java | 9 + .../PipelineStageRetryServiceImpl.java | 181 ++++ .../service/PipelineViewAssembler.java | 129 +++ .../base/core/configs/DataSourceConfig.java | 10 +- .../tms/controller/TestCaseController.java | 31 + .../TmsQualityStandardController.java | 93 ++ .../dto/TmsQualityStandardCriterionRQ.java | 40 + .../dto/TmsQualityStandardCriterionRS.java | 24 + .../core/tms/dto/TmsQualityStandardRQ.java | 28 + .../core/tms/dto/TmsQualityStandardRS.java | 26 + .../dto/TmsTestCaseGenerationMetadataRQ.java | 19 + .../core/tms/dto/TmsTestCaseGenerationRQ.java | 28 + .../core/tms/dto/TmsTestCaseMetricsRS.java | 56 ++ .../base/core/tms/dto/TmsTestCaseOrigin.java | 9 + .../tms/dto/TmsTestCaseQualityScoreRQ.java | 26 + .../base/core/tms/dto/TmsTestCaseRQ.java | 8 + .../base/core/tms/dto/TmsTestCaseRS.java | 6 + .../base/core/tms/dto/TmsTestCaseStatus.java | 9 + .../batch/BatchTestCaseOperationError.java | 7 + .../BatchTestCaseOperationErrorReason.java | 17 + .../TmsTestCasesLifecycleChangedEvent.java | 16 + .../tms/mapper/TmsQualityStandardMapper.java | 70 ++ .../core/tms/mapper/TmsTestCaseMapper.java | 39 +- .../tms/mapper/TmsTestCaseVersionMapper.java | 2 + .../tms/pipeline/AutoReadyOnStagePassed.java | 52 ++ .../core/tms/pipeline/AutoReadySettings.java | 44 + .../pipeline/AutoReadySettingsValidator.java | 34 + ...eshPipelineIterationsOnTestCaseChange.java | 50 + .../TestCaseRelatedEntityResolver.java | 68 ++ .../pipeline/TestCaseReviewContributor.java | 125 +++ .../service/TmsQualityStandardService.java | 15 + .../TmsQualityStandardServiceImpl.java | 128 +++ .../TmsTestCaseExecutionServiceImpl.java | 31 +- .../service/TmsTestCaseQualityService.java | 51 ++ .../TmsTestCaseQualityServiceImpl.java | 196 ++++ .../core/tms/service/TmsTestCaseService.java | 60 ++ .../tms/service/TmsTestCaseServiceImpl.java | 156 +++- .../TmsTestCaseVersionServiceImpl.java | 3 + .../tms/service/TmsTestPlanServiceImpl.java | 17 +- .../QualityStandardRQValidator.java | 22 + .../validation/ValidQualityStandardRQ.java | 25 + .../handler/GlobalExceptionHandler.java | 1 + .../exception/rest/StatusCodeMapping.java | 2 + .../commons/querygen/FilterTarget.java | 8 + .../tms/TmsTestCaseCriteriaConstant.java | 2 + .../PipelineIterationRepository.java | 53 ++ .../dao/aifactory/PipelineRepository.java | 26 + .../dao/aifactory/PipelineSpecifications.java | 107 +++ .../PipelineStageAttemptRepository.java | 12 + .../aifactory/PipelineStageRepository.java | 53 ++ .../tms/PipelineStageTestCaseRepository.java | 51 ++ ...TmsQualityStandardCriterionRepository.java | 17 + .../dao/tms/TmsQualityStandardRepository.java | 16 + ...sTestCaseGenerationMetadataRepository.java | 16 + .../TmsTestCaseQualityScoreRepository.java | 26 + .../dao/tms/TmsTestCaseRepository.java | 10 +- .../persistence/dao/util/RecordMappers.java | 9 +- .../entity/activity/EventObject.java | 5 +- .../entity/aifactory/Pipeline.java | 71 ++ .../entity/aifactory/PipelineAttributes.java | 36 + .../entity/aifactory/PipelineDocument.java | 36 + .../aifactory/PipelineExecutionData.java | 34 + .../entity/aifactory/PipelineIteration.java | 103 +++ .../entity/aifactory/PipelineLinks.java | 36 + .../aifactory/PipelineRelatedEntities.java | 39 + .../entity/aifactory/PipelineSections.java | 36 + .../entity/aifactory/PipelineSettings.java | 39 + .../entity/aifactory/PipelineStage.java | 144 +++ .../aifactory/PipelineStageAttempt.java | 101 +++ .../entity/enums/IntegrationGroupEnum.java | 3 +- .../entity/tms/PipelineStageTestCase.java | 44 + .../entity/tms/TmsQualityStandard.java | 69 ++ .../tms/TmsQualityStandardCriterion.java | 50 + .../persistence/entity/tms/TmsTestCase.java | 12 + .../tms/TmsTestCaseGenerationMetadata.java | 55 ++ .../entity/tms/TmsTestCaseQualityScore.java | 61 ++ .../entity/tms/TmsTestCaseVersion.java | 7 + .../entity/tms/enums/TmsTestCaseOrigin.java | 6 + .../entity/tms/enums/TmsTestCaseStatus.java | 6 + .../persistence/jooq/tables/JTmsTestCase.java | 10 + .../tables/records/JTmsTestCaseRecord.java | 32 +- .../rules/exception/ErrorType.java | 10 + .../resources/schema/manifest.schema.json | 43 - .../schema/pipeline/grade.schema.json | 25 + .../aifactory/PipelineCoreLayeringTest.java | 35 + .../GitHubActionsCiConnectorTest.java | 120 +++ .../connector/GitLabCiConnectorTest.java | 141 +++ .../controller/PipelineControllerTest.java | 339 +++++++ .../integration/PipelineIntegrationTest.java | 624 +++++++++++++ .../PipelineOpenApiIntegrationTest.java | 74 ++ .../aifactory/enums/StatusCategoryTest.java | 53 ++ .../aifactory/mapper/PipelineMapperTest.java | 430 +++++++++ .../service/IngestFingerprintTest.java | 46 + .../service/PipelineAttributeSupportTest.java | 124 +++ .../service/PipelineComparatorTest.java | 171 ++++ .../PipelineDocumentValidatorTest.java | 58 ++ .../PipelineIterationRefresherTest.java | 163 ++++ .../PipelineIterationServiceImplTest.java | 854 ++++++++++++++++++ .../aifactory/service/PipelinePagingTest.java | 35 + .../service/PipelineServiceImplTest.java | 234 +++++ .../PipelineStageRetryServiceImplTest.java | 259 ++++++ .../TmsQualityStandardIntegrationTest.java | 157 ++++ .../TmsTestPlanIntegrationTest.java | 20 + .../TmsQualityStandardControllerTest.java | 167 ++++ .../unit/TmsTestCaseControllerTest.java | 49 + .../pipeline/AutoReadyOnStagePassedTest.java | 92 ++ .../AutoReadySettingsValidatorTest.java | 51 ++ ...ipelineIterationsOnTestCaseChangeTest.java | 65 ++ .../TestCaseRelatedEntityResolverTest.java | 87 ++ .../TestCaseReviewContributorTest.java | 234 +++++ .../TmsQualityStandardServiceImplTest.java | 221 +++++ .../TmsTestCaseExecutionServiceImplTest.java | 154 +++- .../TmsTestCaseQualityServiceImplTest.java | 347 +++++++ .../service/TmsTestCaseServiceImplTest.java | 459 +++++++++- .../service/TmsTestPlanServiceImplTest.java | 30 + .../QualityStandardRQValidatorTest.java | 97 ++ .../commons/querygen/FilterConditionTest.java | 24 + .../dao/IntegrationTypeRepositoryTest.java | 3 +- .../aifactory/pipeline/pipeline-cleanup.sql | 11 + .../db/aifactory/pipeline/pipeline-fill.sql | 102 +++ .../tms-quality-standard-fill.sql | 14 + 175 files changed, 13206 insertions(+), 121 deletions(-) create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/connector/AbstractCiTriggerConnector.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiHttpClient.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiTriggerConnector.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnector.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnector.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/controller/PipelineController.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineCompareRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationDetailRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationPatchRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRefRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationSummaryRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineSettingsRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageAttemptRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRetryRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageSummaryRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStepResultRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineUserRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineWarningRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeDirection.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeRollup.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeType.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeValueType.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOrigin.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategory.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineIterationIngestedEvent.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/event/PipelineStageRetryRequestedEvent.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineAttribute.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineExecution.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineLink.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineSection.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprint.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupport.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparator.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidator.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationFilter.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationIngestResult.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresher.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImpl.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelinePaging.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRelatedEntityResolver.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImpl.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineSettingsValidator.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageContributor.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageReportedHandler.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImpl.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineViewAssembler.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/controller/TmsQualityStandardController.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardCriterionRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsQualityStandardRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationMetadataRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseGenerationRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseMetricsRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseOrigin.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseQualityScoreRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseStatus.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/batch/BatchTestCaseOperationErrorReason.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/event/TmsTestCasesLifecycleChangedEvent.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsQualityStandardMapper.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassed.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettings.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidator.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChange.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolver.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributor.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImpl.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImpl.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidator.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/validation/ValidQualityStandardRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineIterationRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineSpecifications.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageAttemptRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineStageRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/PipelineStageTestCaseRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardCriterionRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsQualityStandardRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseGenerationMetadataRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseQualityScoreRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/Pipeline.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineAttributes.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineDocument.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineExecutionData.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineIteration.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineLinks.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRelatedEntities.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSections.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineSettings.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStage.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStageAttempt.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/PipelineStageTestCase.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandard.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsQualityStandardCriterion.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseGenerationMetadata.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseQualityScore.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseOrigin.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsTestCaseStatus.java create mode 100644 src/main/resources/schema/pipeline/grade.schema.json create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/PipelineCoreLayeringTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitHubActionsCiConnectorTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/connector/GitLabCiConnectorTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/controller/PipelineControllerTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIntegrationTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineOpenApiIntegrationTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/enums/StatusCategoryTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapperTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprintTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupportTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparatorTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidatorTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresherTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImplTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelinePagingTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImplTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineStageRetryServiceImplTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TmsQualityStandardIntegrationTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/controller/unit/TmsQualityStandardControllerTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadyOnStagePassedTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/AutoReadySettingsValidatorTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/RefreshPipelineIterationsOnTestCaseChangeTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseRelatedEntityResolverTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributorTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/service/TmsQualityStandardServiceImplTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseQualityServiceImplTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/validation/QualityStandardRQValidatorTest.java create mode 100644 src/test/resources/db/aifactory/pipeline/pipeline-cleanup.sql create mode 100644 src/test/resources/db/aifactory/pipeline/pipeline-fill.sql create mode 100644 src/test/resources/db/tms/tms-quality-standard/tms-quality-standard-fill.sql 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..3993d6ba8f --- /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 for a given pipelineName (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 identity 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/PipelineCompareRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineCompareRS.java new file mode 100644 index 0000000000..988827dc29 --- /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 identity 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..52bc01180f --- /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: MAIN 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..b1aa46e675 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineIterationRQ.java @@ -0,0 +1,50 @@ +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.Size; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineIterationRQ { + + @NotBlank + @Size(max = 256) + private String pipelineName; + + @Size(max = 64) + @Schema(description = "Kind of the pipeline (e.g. TEST_CASE_GENERATION); recorded when the pipeline is first reported. " + + "Reporting a different type for an existing pipeline is rejected") + private String pipelineType; + + @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 pipelineAttributes; + + @Valid + @Schema(description = "Typed facts about the 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/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..cafb8b3ba4 --- /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 (MAIN 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..d2ee3158af --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineRS.java @@ -0,0 +1,34 @@ +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 PipelineIterationSummaryRS latestIteration; + 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..5e6c895890 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRQ.java @@ -0,0 +1,101 @@ +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 = 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 = "A document produced by the stage, kept exactly as received. Example: the grader's " + + "output {\"total_score\": 90, \"cases\": [...]} on a Grade stage") + private Map document; + + @Size(max = 128) + @Schema(description = "Name of the schema the document must follow. When ReportPortal has a schema with " + + "this name the document is checked against it; a document 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 document that needs no check") + private String documentSchemaName; + + private PipelineStepResultRQ result; + + @Valid + @Schema(description = "Things elsewhere in ReportPortal the stage worked on or produced. Example: an Upload " + + "stage that created test cases TC-12 and TC-13 reports " + + "[{entityType: \"TEST_CASE\", entityId: \"TC-12\"}, {entityType: \"TEST_CASE\", entityId: \"TC-13\"}]. " + + "Each entity type is resolved by its own handler. A report that sends this list replaces the stage's " + + "earlier one; a report that leaves it out keeps it") + private List relatedEntities; + + @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..36ec40d55a --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageRS.java @@ -0,0 +1,63 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StageOrigin; +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 StageOrigin origin; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private List attributes; + @Schema(description = "What the stage is doing or waiting for right now") + private String message; + @Schema(description = "Things elsewhere in ReportPortal the stage reported it worked on or produced, as " + + "reported. Example: an Upload stage that created 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 = "A document the stage reported, exactly as received") + private Map document; + private String documentSchemaName; + @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..ea91201dbd --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineStageSummaryRS.java @@ -0,0 +1,45 @@ +package com.epam.reportportal.base.core.aifactory.dto; + +import com.epam.reportportal.base.core.aifactory.enums.StageOrigin; +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 (the MAIN one is the chip metric). + */ +@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 StageOrigin origin; + private Instant startedAt; + private Instant finishedAt; + private Long durationMillis; + private List attributes; + @Schema(description = "What the stage is doing or waiting for right now") + private String message; +} 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..6b5a0a2a77 --- /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 reported {@code relatedEntities: [{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..42fe9e7992 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/AttributeType.java @@ -0,0 +1,14 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * How the UI places an attribute. + */ +@Schema(description = "MAIN - shown in the meta line / as the stage chip metric; TAG - shown as a chip (e.g. env:beta5); " + + "HIDDEN - searchable but not displayed") +public enum AttributeType { + MAIN, + TAG, + HIDDEN +} 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/StageOrigin.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOrigin.java new file mode 100644 index 0000000000..ce6d349cf7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOrigin.java @@ -0,0 +1,19 @@ +package com.epam.reportportal.base.core.aifactory.enums; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Where a stage comes from, which also tells who keeps it up to date. + *

+ * Example: in a test case generation iteration the Create, Grade and Upload stages are {@link #REPORTED} by + * the agent's CI job, while the Review stage is {@link #DERIVED}: ReportPortal creates it and recounts "2 / 3 + * Ready" whenever a reviewer changes a test case, so the CI job never reports it. + */ +@Schema(description = "REPORTED - sent by the pipeline (e.g. Create, Grade, Upload); DERIVED - 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 StageOrigin { + /** Sent by the pipeline itself. */ + REPORTED, + /** Calculated and maintained by ReportPortal; the pipeline cannot report it. */ + DERIVED +} 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..e7952dba84 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java @@ -0,0 +1,289 @@ +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.StageOrigin; +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.PipelineDocument; +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, PipelineIterationSummaryRS latestIteration) { + 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) + .latestIteration(latestIteration) + .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()) + .origin(stage.getOrigin()) + .startedAt(stage.getStartedAt()) + .finishedAt(stage.getFinishedAt()) + .durationMillis(durationMillis(stage.getStartedAt(), stage.getFinishedAt())) + .attributes(attributeList(stage.getAttributes())) + .message(stage.getMessage()) + .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()) + .origin(stage.getOrigin()) + .startedAt(stage.getStartedAt()) + .finishedAt(stage.getFinishedAt()) + .durationMillis(durationMillis(stage.getStartedAt(), stage.getFinishedAt())) + .attributes(attributeList(stage.getAttributes())) + .message(stage.getMessage()) + .relatedEntities(relatedEntityList(stage.getRelatedEntities())) + .sections(sectionList(stage.getSections())) + .result(toStepResultRS(stage.getResultType(), stage.getResultRef())) + .document(stage.getDocument() == null ? null : stage.getDocument().getContent()) + .documentSchemaName(stage.getDocumentSchemaName()) + .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.setOrigin(StageOrigin.REPORTED); + 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.setMessage(rq.getMessage()); + if (rq.getRelatedEntities() != null) { + stage.setRelatedEntities(new PipelineRelatedEntities(rq.getRelatedEntities())); + } + stage.setLinks(rq.getLinks() == null || rq.getLinks().isEmpty() ? null : new PipelineLinks(rq.getLinks())); + stage.setDocument(rq.getDocument() == null ? null : new PipelineDocument(rq.getDocument())); + stage.setDocumentSchemaName(rq.getDocumentSchemaName()); + 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..d3475aeeff --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineAttribute.java @@ -0,0 +1,78 @@ +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; + + @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; unordered attributes keep 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 what the owner is about (e.g. the requirement). Iterations whose identity " + + "attributes are equal cover the same scope and can be compared case by case") + private boolean identity; + + /** + * 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..9129068aad --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/model/PipelineSection.java @@ -0,0 +1,149 @@ +package com.epam.reportportal.base.core.aifactory.model; + +import com.epam.reportportal.base.core.aifactory.enums.AttributeValueType; +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.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; + + @Valid + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List columns; + + @Valid + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List rows; + + @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; + } + + /** + * 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..20818fab8f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupport.java @@ -0,0 +1,160 @@ +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()) + .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()) + .identity(attribute.isIdentity()) + .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.MAIN) + .valueType(template.getValueType()) + .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..9d73d93ba7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineComparator.java @@ -0,0 +1,206 @@ +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 identity 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 = identity(baseline); + var candidateScope = identity(candidate); + return !baselineScope.isEmpty() && baselineScope.equals(candidateScope); + } + + private Set identity(PipelineIterationDetailRS iteration) { + if (iteration.getAttributes() == null) { + return Set.of(); + } + return iteration.getAttributes().stream() + .filter(PipelineAttribute::isIdentity) + .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()) + .origin(stage.getOrigin()) + .startedAt(stage.getStartedAt()) + .finishedAt(stage.getFinishedAt()) + .durationMillis(stage.getDurationMillis()) + .attributes(stage.getAttributes()) + .message(stage.getMessage()) + .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/PipelineDocumentValidator.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidator.java new file mode 100644 index 0000000000..440aef7438 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidator.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 a document 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 documents of their own. + */ +@Component +@RequiredArgsConstructor +public class PipelineDocumentValidator { + + 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 document is not valid, or empty when it is valid or there is no schema to check it with + */ + public Optional validate(String documentSchemaName, Map document) { + if (documentSchemaName == null || document == null || !SAFE_REF.matcher(documentSchemaName).matches() + || !new ClassPathResource(RESOURCE_BASE + documentSchemaName + SUFFIX).exists()) { + return Optional.empty(); + } + try { + var messages = schemaValidator.validate(SCHEMA_BASE + documentSchemaName + SUFFIX, objectMapper.valueToTree(document)); + if (messages.isEmpty()) { + return Optional.empty(); + } + var reason = "Document does not match schema '" + documentSchemaName + "': " + + 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 '" + documentSchemaName + "'", e); + } + } +} 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..35520e024b --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresher.java @@ -0,0 +1,114 @@ +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.toSet()); + var category = categories.isEmpty() ? derivedWhenNothingCounts(stages) : StatusCategory.aggregate(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); + } + } + } + + /** + * 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..5bcb34c642 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImpl.java @@ -0,0 +1,526 @@ +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.StageOrigin; +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 PipelineDocumentValidator documentValidator; + + @Override + @Transactional + public PipelineIterationIngestResult createPipelineIteration(Long projectId, Long userId, + PipelineIterationRQ rq, String idempotencyKey) { + validateSize(rq); + var stageRQs = rq.getStages() == null ? List.of() : rq.getStages(); + validateStages(stageRQs); + + var pipeline = pipelineRepository + .findByProjectIdAndName(projectId, rq.getPipelineName()) + .orElseGet(() -> createPipeline(projectId, rq.getPipelineName(), rq.getPipelineType())); + // 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 (rq.getRerunOfIterationId() != null) { + var source = pipelineIterationRepository.findById(rq.getRerunOfIterationId()) + .filter(it -> it.getPipeline().getId().equals(lockedPipeline.getId())) + .orElseThrow(() -> new ReportPortalException(ErrorType.NOT_FOUND, + "Pipeline iteration '" + rq.getRerunOfIterationId() + "' for pipeline '" + + lockedPipeline.getName() + "'")); + iteration.setRerun(true); + iteration.setRerunOfIterationId(source.getId()); + } + if (idempotencyKey != null) { + iteration.setIdempotencyKey(idempotencyKey); + iteration.setIdempotencyHash(fingerprint); + } + iteration.setStartedAt(rq.getStartedAt()); + iteration.setFinishedAt(rq.getFinishedAt()); + setAttributes(iteration, PipelineAttributeSupport.normalize(rq.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); + checkDocument(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, stageRQs.get(i).getRelatedEntities())); + 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; + if (existing == null) { + rq.setStageKey(stageKey); + stage = pipelineMapper.toStageEntity(rq); + checkDocument(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.getOrigin() == StageOrigin.DERIVED) { + 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); + } + 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); + checkDocument(stage); + stage = pipelineStageRepository.save(stage); + becamePassed = previousCategory != StatusCategory.PASSED && stage.getStatusCategory() == StatusCategory.PASSED; + } + + var warnings = resolveRelatedEntities(projectId, iteration.getPipeline(), stage, rq.getRelatedEntities()); + 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 document 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 checkDocument(PipelineStage stage) { + var document = stage.getDocument() == null ? null : stage.getDocument().getContent(); + documentValidator.validate(stage.getDocumentSchemaName(), document).ifPresent(reason -> { + stage.setDocument(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.MAIN).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.getPipelineType(); + 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.getPipelineAttributes() != null && !rq.getPipelineAttributes().isEmpty()) { + pipeline.setAttributes(PipelineAttributeSupport.toColumn( + PipelineAttributeSupport.merge(pipeline.getAttributes(), rq.getPipelineAttributes(), 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 related entities a stage reported 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 are kept as reported. + *

+ * Example: a stage reports {@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, + List relatedEntities) { + if (relatedEntities == null) { + 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/PipelineRelatedEntityResolver.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRelatedEntityResolver.java new file mode 100644 index 0000000000..4563dced6c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRelatedEntityResolver.java @@ -0,0 +1,34 @@ +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 reports + * {@code relatedEntities: [{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..47c6960718 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImpl.java @@ -0,0 +1,101 @@ +package com.epam.reportportal.base.core.aifactory.service; + +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.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.function.Function; +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 PipelineViewAssembler viewAssembler; + 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, null)); + } + + var iterationsCountByPipelineId = pipelineIterationRepository + .countByPipelineIdIn(pipelineIds) + .stream() + .collect(Collectors.toMap( + PipelineIterationRepository.PipelineIterationCount::getPipelineId, + PipelineIterationRepository.PipelineIterationCount::getCount) + ); + var latestIterations = pipelineIterationRepository.findLatestByPipelineIdIn(pipelineIds); + var latestSummaryByPipelineId = viewAssembler.summaries(latestIterations) + .stream() + .collect(Collectors.toMap(PipelineIterationSummaryRS::getPipelineId, Function.identity())); + + return page.map(pipeline -> pipelineMapper.toPipelineRS(pipeline, + iterationsCountByPipelineId.getOrDefault(pipeline.getId(), 0L), + latestSummaryByPipelineId.get(pipeline.getId()))); + } + + @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()); + var latest = pipelineIterationRepository + .findFirstByPipelineIdOrderByIterationNumberDesc(pipeline.getId()) + .map(viewAssembler::summary) + .orElse(null); + return pipelineMapper.toPipelineRS(pipeline, iterationsCount, latest); + } +} 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..dc14cb6d85 --- /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.StageOrigin; +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.getOrigin() == StageOrigin.DERIVED + || 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 e1920d20ba..ad003fb013 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 @@ -1,10 +1,11 @@ package com.epam.reportportal.base.core.tms.mapper; import com.epam.reportportal.base.core.tms.TmsTestCasePriority; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseImportRQ; 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.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.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 @@ -98,6 +115,7 @@ public Page convert( @Mapping(target = "attributes", ignore = true) @Mapping(target = "versions", ignore = true) @Mapping(target = "priority", expression = "java(normalizePriority(tmsTestCaseRQ.getPriority()))") + @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); @@ -122,6 +140,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, @@ -134,6 +154,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); @@ -162,6 +183,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..a7b41be8b7 --- /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.MAIN).valueType(AttributeValueType.NUMBER) + .rollup(AttributeRollup.SUM).build(), + PipelineAttribute.builder().key(THRESHOLD_ATTRIBUTE_KEY).label("Auto-Ready threshold") + .value(String.valueOf(autoReady.threshold())).type(AttributeType.HIDDEN) + .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..ec612d2ab7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributor.java @@ -0,0 +1,125 @@ +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.StageOrigin; +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.getOrigin() != StageOrigin.DERIVED) { + // 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.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.MAIN).valueType(AttributeValueType.TEXT).build(), + PipelineAttribute.builder().key("ready").label("Ready").value(String.valueOf(ready)) + .type(AttributeType.HIDDEN).valueType(AttributeValueType.NUMBER).build(), + PipelineAttribute.builder().key("total").label("Test Cases").value(String.valueOf(total)) + .type(AttributeType.HIDDEN).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.getOrigin() == StageOrigin.DERIVED) + .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.MAIN).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.setOrigin(StageOrigin.DERIVED); + 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 8df9294765..4f37f92146 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..634980a794 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineIterationRepository.java @@ -0,0 +1,53 @@ +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); + + /** + * Batched form of {@link #findFirstByPipelineIdOrderByIterationNumberDesc(Long)} — the latest + * iteration per pipeline in one query instead of one per pipeline. + */ + @Query("SELECT it FROM PipelineIteration it WHERE it.pipeline.id IN :pipelineIds " + + "AND it.iterationNumber = (SELECT MAX(it2.iterationNumber) FROM PipelineIteration it2 " + + "WHERE it2.pipeline.id = it.pipeline.id)") + List findLatestByPipelineIdIn(@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/PipelineDocument.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineDocument.java new file mode 100644 index 0000000000..854bec062c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineDocument.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; + +/** + * A document 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 PipelineDocument extends JsonbUserType implements Serializable { + + private Map content = new LinkedHashMap<>(); + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public PipelineDocument(Map content) { + this.content = content == null ? new LinkedHashMap<>() : new LinkedHashMap<>(content); + } + + @JsonValue + public Map getContent() { + return content; + } + + @Override + public Class returnedClass() { + return PipelineDocument.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/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..9d7c2d5bd9 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineStage.java @@ -0,0 +1,144 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.aifactory; + +import com.epam.reportportal.base.core.aifactory.enums.StageOrigin; +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 document} keeps a reported document 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 = "origin", nullable = false) + private StageOrigin origin = StageOrigin.REPORTED; + + @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 = "message") + private String message; + + @Type(PipelineLinks.class) + @Column(name = "links", columnDefinition = "jsonb") + private PipelineLinks links; + + @Type(PipelineDocument.class) + @Column(name = "document", columnDefinition = "jsonb") + private PipelineDocument document; + + @Column(name = "document_schema_name") + private String documentSchemaName; + + @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 ac81590915..c3c6530397 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 1a7a9c9fd0..ceba59401e 100644 --- a/src/main/resources/schema/manifest.schema.json +++ b/src/main/resources/schema/manifest.schema.json @@ -140,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, 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/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..191bffe5ec --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/PipelineControllerTest.java @@ -0,0 +1,339 @@ +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.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() + .pipelineName("factory-tc-gen") + .stages(List.of(PipelineStageRQ.builder() + .stageKey("stage-gen-tc").sequence(1).status("Passed").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() + .pipelineName("factory-tc-gen") + .stages(List.of(PipelineStageRQ.builder() + .stageKey("stage-gen-tc").sequence(1).status("Passed").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..e747a366fc --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIntegrationTest.java @@ -0,0 +1,624 @@ +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.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.MAIN).build(); + } + + // ------------------------------------------------------------------ pipelines + + @Test + void getPipelines_ShouldReturnOnlyThisProjectsPipelinesWithTypeAttributesAndLatestIterationSummary() 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.iterationNumber").value(2)) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].latestIteration.status").value("In review")) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].latestIteration.statusCategory").value("WAITING")) + .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("MAIN", 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() + .pipelineName("brand-new-pipeline") + .pipelineType("TEST_AUTOMATION") + .pipelineAttributes(List.of(main("repo", "org/auto"))) + .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())) + .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), new PipelineSection.EntityReference("TEST_CASE", "TC-404"))) + .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().pipelineName("factory-tc-gen").pipelineType("TEST_AUTOMATION").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() + .pipelineName("factory-tc-gen") + .stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Passed").build())) + .build(); + var other = PipelineIterationRQ.builder() + .pipelineName("factory-tc-gen") + .stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Failed").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().pipelineName("factory-tc-gen").rerunOfIterationId(901L).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() + .pipelineName("brand-new-automation-pipeline") + .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(); + + 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 = """ + {"pipelineName": "scalar-cells", + "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().pipelineName("incremental").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().pipelineName("attempts").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().pipelineName("unknown-status").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() + .pipelineName("factory-tc-gen") + .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") + .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), + new PipelineSection.EntityReference("TEST_CASE", "TC-901"))).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("DERIVED", review.get("origin").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().pipelineName("factory-tc-gen").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/PipelineOpenApiIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineOpenApiIntegrationTest.java new file mode 100644 index 0000000000..2c158a0465 --- /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", "origin"); + 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..ddad9f2712 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapperTest.java @@ -0,0 +1,430 @@ +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.dto.PipelineIterationRefRS; +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.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.StageOrigin; +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.PipelineDocument; +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() { + var summary = PipelineIterationSummaryRS.builder().id(7L).build(); + pipeline.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("repo").value("org/repo").build()))); + + var result = mapper.toPipelineRS(pipeline, 5L, summary); + + 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(summary, result.getLatestIteration()); + assertEquals(pipeline.getCreatedAt(), result.getCreatedAt()); + } + + @Test + void toIterationSummaryRS_ShouldMapHeaderFactsAndGivenAttributes() { + var attributes = List.of(PipelineAttribute.builder().key("requirement").value("US-1") + .type(AttributeType.MAIN).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.setOrigin(StageOrigin.REPORTED); + stage.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("score").value("90").type(AttributeType.MAIN).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_ShouldBeKeptAsReportedAndReturnedOnTheStage() { + var rq = PipelineStageRQ.builder().stageKey("upload").status("Passed") + .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-12"), + new PipelineSection.EntityReference("LAUNCH", "7"))) + .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_WhenTheReportLeavesThemOut_ShouldKeepTheEarlierOnes() { + var stage = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("upload").status("Running") + .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-12"))).build()); + + mapper.applyStage(stage, PipelineStageRQ.builder().status("Passed").build()); + + assertEquals("TC-12", stage.getRelatedEntities().getItems().get(0).getEntityId()); + } + + @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.setOrigin(StageOrigin.DERIVED); + + var result = mapper.toStageRS(stage); + + assertEquals(50L, result.getId()); + assertEquals("In progress", result.getStatus()); + assertEquals(StatusCategory.WAITING, result.getStatusCategory()); + assertEquals(StageOrigin.DERIVED, result.getOrigin()); + assertNull(result.getLinks()); + assertNull(result.getResult()); + assertNull(result.getParentStageId()); + assertNull(result.getSections()); + assertNull(result.getDocument()); + assertFalse(result.isRetryable()); + } + + @Test + void toStageRS_WithDetails_ShouldMapAttributesSectionsDocumentResultAndLinks() { + 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.setDocument(new PipelineDocument(Map.of("total_score", 90))); + stage.setDocumentSchemaName("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.getDocument()); + assertEquals("grade.schema.json@1", result.getDocumentSchemaName()); + 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())) + .document(Map.of("k", "v")) + .documentSchemaName("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(StageOrigin.REPORTED, stage.getOrigin()); + 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.getDocument().getContent()); + assertEquals("s@1", stage.getDocumentSchemaName()); + 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.getDocument()); + 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/service/IngestFingerprintTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprintTest.java new file mode 100644 index 0000000000..5c7753f6d4 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/IngestFingerprintTest.java @@ -0,0 +1,46 @@ +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.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 document, String status) { + return PipelineIterationRQ.builder().pipelineName("p") + .stages(List.of(PipelineStageRQ.builder().stageKey("s").sequence(1).status(status).document(document).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..98769dea12 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineAttributeSupportTest.java @@ -0,0 +1,124 @@ +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.MAIN, result.get(0).getType()); + assertEquals(AttributeValueType.MONEY, result.get(0).getValueType()); + } + + @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..d08f9a6cda --- /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.MAIN).identity(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.MAIN).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/PipelineDocumentValidatorTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidatorTest.java new file mode 100644 index 0000000000..e1c68028f0 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidatorTest.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 PipelineDocumentValidatorTest { + + private PipelineDocumentValidator 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 PipelineDocumentValidator(new JsonSchemaValidator(config), new ObjectMapper()); + } + + @Test + void validate_WithAValidGradeDocument_ShouldHaveNoReason() { + var document = Map.of("total_score", 83, "warnings", List.of("w"), + "cases", List.of(Map.of("name", "Case", "total_score", 42))); + + assertTrue(validator.validate("grade", document).isEmpty()); + } + + @Test + void validate_WithAGradeDocumentWithoutSuiteScore_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 document = Map.of("total_score", 83, + "cases", List.of(Map.of("name", "Case", "total_score", "high"))); + + assertTrue(validator.validate("grade", document).isPresent()); + } + + @Test + void validate_WithoutASchemaOrDocumentOrWithAnUnsafeReference_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/PipelineIterationRefresherTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresherTest.java new file mode 100644 index 0000000000..8b39b7890f --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationRefresherTest.java @@ -0,0 +1,163 @@ +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_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.MAIN) + .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..736624e566 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineIterationServiceImplTest.java @@ -0,0 +1,854 @@ +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.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.StageOrigin; +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 PipelineDocumentValidator documentValidator; + + 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, + documentValidator); + + 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).relatedEntities(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.setOrigin(StageOrigin.REPORTED); + 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().pipelineName("new-pipeline").pipelineType("TEST_CASE_GENERATION") + .stages(List.of(stageRQ("create", "Passed", null))).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().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen").pipelineType("TEST_CASE_GENERATION") + .pipelineAttributes(List.of(PipelineAttribute.builder().key("repo").value("org/repo").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().pipelineName("factory-tc-gen").pipelineType("TEST_CASE_GENERATION").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().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen") + .attributes(List.of( + PipelineAttribute.builder().key("requirement").value("US-TMS-EXP-002").build(), + PipelineAttribute.builder().key("env").value("beta5").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().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("create", "Passed", null), stageRQ("grade", "Running", null))).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().pipelineName("factory-tc-gen") + .stages(List.of( + PipelineStageRQ.builder().stageKey("parent").status("Passed").build(), + PipelineStageRQ.builder().stageKey("child").status("Passed").parentStageKey("parent").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().pipelineName("factory-tc-gen") + .stages(List.of(PipelineStageRQ.builder().stageKey("child").status("Passed") + .parentStageKey("missing-parent").build())) + .build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + } + + @Test + void create_WithCycleInParentChain_ShouldThrowBadRequest() { + var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + .stages(List.of( + PipelineStageRQ.builder().stageKey("a").status("Passed").parentStageKey("b").build(), + PipelineStageRQ.builder().stageKey("b").status("Passed").parentStageKey("a").build())) + .build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); + } + + @Test + void create_WithDuplicateOrBlankStageKeys_ShouldThrowBadRequest() { + var duplicate = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("a", "Passed", null), stageRQ("a", "Passed", null))).build(); + var blank = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ(" ", "Passed", null))).build(); + + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, duplicate)); + assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, blank)); + } + + @Test + void create_WithOversizedReport_ShouldBeRejected() { + var document = Map.of("blob", "x".repeat((int) PipelineIterationServiceImpl.MAX_REPORT_BYTES)); + var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + .stages(List.of(PipelineStageRQ.builder().stageKey("grade").status("Passed").document(document).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().pipelineName("factory-tc-gen").rerunOfIterationId(99L).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().pipelineName("factory-tc-gen").rerunOfIterationId(99L).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().pipelineName("factory-tc-gen").build()); + + verify(eventPublisher).publishEvent(any(com.epam.reportportal.base.core.aifactory.event.PipelineIterationIngestedEvent.class)); + } + + // ------------------------------------------------------------------ idempotency + + @Test + void create_WithBlankOrTooLongIdempotencyKey_ShouldThrowBadRequest() { + var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen").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().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("upload", "Passed", relatedTestCases("TC-1", "TC-404")))).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().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("create", "Passed", null), stageRQ("upload", "Running", null))).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().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("upload", "Passed", null))).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().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("upload", "Passed", List.of(new PipelineSection.EntityReference("LAUNCH", "12"))))) + .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().pipelineName("factory-tc-gen") + .stages(List.of(stageRQ("upload", "Passed", null))).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_WithADocumentThatFailsItsSchema_ShouldFailTheStageAndNotKeepTheDocument() { + persistedIteration(10L); + when(pipelineStageRepository.findByIterationIdOrderBySequenceAsc(10L)).thenReturn(List.of()); + when(documentValidator.validate(org.mockito.ArgumentMatchers.eq("grade"), any())) + .thenReturn(Optional.of("total_score is missing")); + var rq = PipelineStageRQ.builder().status("Passed").documentSchemaName("grade") + .document(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.getDocument()); + 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") + .relatedEntities(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_WithEmptyRefs_ShouldLetTheResolverClearTheStage() { + persistedIteration(10L); + persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); + + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running") + .relatedEntities(List.of()).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).setOrigin(StageOrigin.DERIVED); + + 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/PipelineServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImplTest.java new file mode 100644 index 0000000000..62d564abe7 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineServiceImplTest.java @@ -0,0 +1,234 @@ +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.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.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.PipelineIteration; +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 PipelineViewAssembler viewAssembler; + @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(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipelineId)) + .thenReturn(Optional.empty()); + when(pipelineMapper.toPipelineRS(pipeline, 0L, null)).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 patchSettings_ShouldReturnTheLatestIterationSummary() { + var latest = new PipelineIteration(); + var summary = PipelineIterationSummaryRS.builder().id(42L).build(); + var expectedRS = PipelineRS.builder().id(pipelineId).build(); + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); + when(pipelineRepository.save(pipeline)).thenReturn(pipeline); + when(pipelineIterationRepository.countByPipelineId(pipelineId)).thenReturn(3L); + when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipelineId)) + .thenReturn(Optional.of(latest)); + when(viewAssembler.summary(latest)).thenReturn(summary); + when(pipelineMapper.toPipelineRS(pipeline, 3L, summary)).thenReturn(expectedRS); + + assertEquals(expectedRS, + sut.patchPipelineSettings(projectId, pipelineId, PipelineSettingsRQ.builder().settings(Map.of()).build())); + } + + @Test + void listPipelines_ShouldBatchIterationCountsAndLatestIterationSummariesAcrossThePage() { + var offsetRequest = new OffsetRequest(0, 50); + var page = new PageImpl<>(List.of(pipeline)); + var latestIteration = new PipelineIteration(); + latestIteration.setId(42L); + latestIteration.setPipeline(pipeline); + var summary = PipelineIterationSummaryRS.builder().id(42L).pipelineId(pipelineId).build(); + 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(pipelineIterationRepository.findLatestByPipelineIdIn(List.of(pipelineId))) + .thenReturn(List.of(latestIteration)); + when(viewAssembler.summaries(List.of(latestIteration))).thenReturn(List.of(summary)); + when(pipelineMapper.toPipelineRS(pipeline, 3L, summary)).thenReturn(expectedRS); + + var result = sut.getPipelines(projectId, null, offsetRequest); + + assertEquals(List.of(expectedRS), result.getContent()); + verify(pipelineIterationRepository, never()).countByPipelineId(any()); + verify(pipelineIterationRepository, never()).findFirstByPipelineIdOrderByIterationNumberDesc(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(pipelineIterationRepository.findLatestByPipelineIdIn(List.of(pipelineId))).thenReturn(List.of()); + when(viewAssembler.summaries(List.of())).thenReturn(List.of()); + when(pipelineMapper.toPipelineRS(pipeline, 0L, null)).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..b72a1db63b --- /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.StageOrigin; +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.setOrigin(StageOrigin.DERIVED); + 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..04fa2fef02 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributorTest.java @@ -0,0 +1,234 @@ +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.StageOrigin; +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.setOrigin(StageOrigin.REPORTED); + 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(StageOrigin.DERIVED, review.getOrigin()); + 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("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.setOrigin(StageOrigin.DERIVED); + 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/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..01ebdc5a74 --- /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": "MAIN", "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": "MAIN", "valueType": "TEXT", "identity": true}, + {"key": "trigger", "label": "Trigger", "value": "CI - booking pack", "type": "MAIN", "valueType": "TEXT"}, + {"key": "suiteScore", "label": "Suite score", "value": "86", "type": "MAIN", "valueType": "SCORE", "direction": "HIGHER_BETTER"}, + {"key": "cost", "label": "Cost", "value": "0.61", "type": "MAIN", "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": "MAIN", "valueType": "TEXT", "identity": true}, + {"key": "trigger", "label": "Trigger", "value": "CI - booking pack retry", "type": "MAIN", "valueType": "TEXT"}, + {"key": "suiteScore", "label": "Suite score", "value": "72", "type": "MAIN", "valueType": "SCORE", "direction": "HIGHER_BETTER"}, + {"key": "cost", "label": "Cost", "value": "0.98", "type": "MAIN", "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": "MAIN", "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)); From b23c3c5dfd209b09f3553553274edc4565606ed3 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 12:06:22 +0300 Subject: [PATCH 02/12] Pipelines implementation --- .../controller/PipelineController.java | 4 +- .../core/aifactory/dto/PipelineCompareRS.java | 2 +- .../aifactory/dto/PipelineDefinitionRQ.java | 49 ++++++++++++ .../dto/PipelineIterationDetailRS.java | 2 +- .../aifactory/dto/PipelineIterationRQ.java | 18 +---- .../dto/PipelineIterationSummaryRS.java | 2 +- .../core/aifactory/dto/PipelineStageRQ.java | 13 ++-- .../core/aifactory/dto/PipelineStageRS.java | 14 +++- .../aifactory/dto/PipelineStageSummaryRS.java | 11 ++- .../core/aifactory/dto/PipelineWarningRS.java | 2 +- .../core/aifactory/enums/AttributeType.java | 15 ++-- .../core/aifactory/enums/SectionRole.java | 17 ++++ .../core/aifactory/enums/StageOrigin.java | 19 ----- .../base/core/aifactory/enums/StageOwner.java | 20 +++++ .../core/aifactory/mapper/PipelineMapper.java | 19 +++-- .../aifactory/model/PipelineAttribute.java | 16 +++- .../core/aifactory/model/PipelineSection.java | 50 ++++++++++++ .../service/PipelineAttributeSupport.java | 6 +- .../aifactory/service/PipelineComparator.java | 14 ++-- .../service/PipelineIterationRefresher.java | 8 +- .../service/PipelineIterationServiceImpl.java | 39 ++++++---- .../PipelineRelatedEntityResolver.java | 3 +- .../PipelineStageRetryServiceImpl.java | 4 +- .../tms/pipeline/AutoReadyOnStagePassed.java | 4 +- .../pipeline/TestCaseReviewContributor.java | 17 ++-- .../entity/aifactory/PipelineStage.java | 9 ++- .../base/core/aifactory/PipelineTestData.java | 29 +++++++ .../controller/PipelineControllerTest.java | 5 +- .../integration/PipelineIntegrationTest.java | 41 +++++----- .../PipelineOpenApiIntegrationTest.java | 2 +- .../aifactory/mapper/PipelineMapperTest.java | 41 ++++++---- .../aifactory/model/PipelineSectionTest.java | 59 ++++++++++++++ .../service/IngestFingerprintTest.java | 3 +- .../service/PipelineAttributeSupportTest.java | 21 ++++- .../service/PipelineComparatorTest.java | 4 +- .../PipelineIterationRefresherTest.java | 22 +++++- .../PipelineIterationServiceImplTest.java | 77 ++++++++++--------- .../PipelineStageRetryServiceImplTest.java | 4 +- .../TestCaseReviewContributorTest.java | 9 ++- .../db/aifactory/pipeline/pipeline-fill.sql | 20 ++--- 40 files changed, 508 insertions(+), 206 deletions(-) create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/SectionRole.java delete mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOrigin.java create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOwner.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/PipelineTestData.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/model/PipelineSectionTest.java 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 index 3993d6ba8f..f2126cfb6c 100644 --- 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 @@ -137,7 +137,7 @@ public Page getPipelineIterations( @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 for a given pipelineName (unique per project) and records its type. The iteration may " + + "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 " @@ -218,7 +218,7 @@ public PipelineIterationDetailRS getPipelineIteration( @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 identity attributes) result rows are joined side by side.") + + "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, 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 index 988827dc29..539697e4b7 100644 --- 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 @@ -25,7 +25,7 @@ public class PipelineCompareRS { private PipelineIterationDetailRS baseline; private PipelineIterationDetailRS candidate; - @Schema(description = "True when the identity attributes of both iterations are equal (e.g. the same requirement), " + @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; diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java new file mode 100644 index 0000000000..949c72b3c1 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java @@ -0,0 +1,49 @@ +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.Size; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Which pipeline an iteration belongs to, and what is known about the pipeline itself. 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"}]}}. The attributes + * describe the pipeline (its repository); the iteration's own attributes (requirement, model, environment) + * are sent next to this object, not inside it. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PipelineDefinitionRQ { + + @NotBlank + @Size(max = 256) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "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; + + public static PipelineDefinitionRQ named(String name) { + return PipelineDefinitionRQ.builder().name(name).build(); + } +} 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 index 52bc01180f..78e4ecdc06 100644 --- 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 @@ -30,7 +30,7 @@ public class PipelineIterationDetailRS { private String status; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private StatusCategory statusCategory; - @Schema(description = "Typed facts about the iteration: MAIN ones form the meta line and totals, TAG ones are chips") + @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; 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 index b1aa46e675..89c6ae933a 100644 --- 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 @@ -3,8 +3,7 @@ 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.Size; +import jakarta.validation.constraints.NotNull; import java.time.Instant; import java.util.List; import lombok.AllArgsConstructor; @@ -18,19 +17,10 @@ @AllArgsConstructor public class PipelineIterationRQ { - @NotBlank - @Size(max = 256) - private String pipelineName; - - @Size(max = 64) - @Schema(description = "Kind of the pipeline (e.g. TEST_CASE_GENERATION); recorded when the pipeline is first reported. " - + "Reporting a different type for an existing pipeline is rejected") - private String pipelineType; - + @NotNull @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 pipelineAttributes; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "The pipeline this iteration belongs to") + private PipelineDefinitionRQ pipeline; @Valid @Schema(description = "Typed facts about the iteration: requirement, trigger, model, environment, links, tags, ...") 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 index cafb8b3ba4..83a2ed85bb 100644 --- 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 @@ -12,7 +12,7 @@ import lombok.NoArgsConstructor; /** - * An iteration as shown on a list card: header facts as attributes (MAIN in the meta line, TAG as chips), + * 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 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 index 5e6c895890..0390c541df 100644 --- 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 @@ -66,6 +66,11 @@ public class PipelineStageRQ { @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; @@ -87,14 +92,6 @@ public class PipelineStageRQ { private PipelineStepResultRQ result; - @Valid - @Schema(description = "Things elsewhere in ReportPortal the stage worked on or produced. Example: an Upload " - + "stage that created test cases TC-12 and TC-13 reports " - + "[{entityType: \"TEST_CASE\", entityId: \"TC-12\"}, {entityType: \"TEST_CASE\", entityId: \"TC-13\"}]. " - + "Each entity type is resolved by its own handler. A report that sends this list replaces the stage's " - + "earlier one; a report that leaves it out keeps it") - private List relatedEntities; - @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 index 36ec40d55a..73bfab13b0 100644 --- 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 @@ -1,6 +1,6 @@ package com.epam.reportportal.base.core.aifactory.dto; -import com.epam.reportportal.base.core.aifactory.enums.StageOrigin; +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; @@ -35,15 +35,21 @@ public class PipelineStageRS { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private StatusCategory statusCategory; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - private StageOrigin origin; + 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 = "Things elsewhere in ReportPortal the stage reported it worked on or produced, as " - + "reported. Example: an Upload stage that created TC-12 returns " + @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") 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 index ea91201dbd..3752629f6c 100644 --- 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 @@ -1,6 +1,6 @@ package com.epam.reportportal.base.core.aifactory.dto; -import com.epam.reportportal.base.core.aifactory.enums.StageOrigin; +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; @@ -13,7 +13,7 @@ import lombok.NoArgsConstructor; /** - * A stage as shown in a chip row: status plus its attributes (the MAIN one is the chip metric). + * A stage as shown in a chip row: status plus its attributes (see summary for the chip text). */ @Data @Builder @@ -35,11 +35,16 @@ public class PipelineStageSummaryRS { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private StatusCategory statusCategory; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - private StageOrigin origin; + 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/PipelineWarningRS.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineWarningRS.java index 6b5a0a2a77..7f1d0e147d 100644 --- 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 @@ -10,7 +10,7 @@ /** * Something in a report that was accepted but not fully applied. Only returned in the response to a report. *

- * Example: a stage reported {@code relatedEntities: [{entityType: "TEST_CASE", entityId: "TC-404"}]} but the + * 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"]}}. */ 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 index 42fe9e7992..486aaf21eb 100644 --- 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 @@ -3,12 +3,17 @@ import io.swagger.v3.oas.annotations.media.Schema; /** - * How the UI places an attribute. + * 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 = "MAIN - shown in the meta line / as the stage chip metric; TAG - shown as a chip (e.g. env:beta5); " - + "HIDDEN - searchable but not displayed") +@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 { - MAIN, + META, TAG, - HIDDEN + NONE } 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/StageOrigin.java b/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOrigin.java deleted file mode 100644 index ce6d349cf7..0000000000 --- a/src/main/java/com/epam/reportportal/base/core/aifactory/enums/StageOrigin.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.epam.reportportal.base.core.aifactory.enums; - -import io.swagger.v3.oas.annotations.media.Schema; - -/** - * Where a stage comes from, which also tells who keeps it up to date. - *

- * Example: in a test case generation iteration the Create, Grade and Upload stages are {@link #REPORTED} by - * the agent's CI job, while the Review stage is {@link #DERIVED}: ReportPortal creates it and recounts "2 / 3 - * Ready" whenever a reviewer changes a test case, so the CI job never reports it. - */ -@Schema(description = "REPORTED - sent by the pipeline (e.g. Create, Grade, Upload); DERIVED - 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 StageOrigin { - /** Sent by the pipeline itself. */ - REPORTED, - /** Calculated and maintained by ReportPortal; the pipeline cannot report it. */ - DERIVED -} 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/mapper/PipelineMapper.java b/src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java index e7952dba84..4affba9d6d 100644 --- 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 @@ -10,7 +10,7 @@ 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.StageOrigin; +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; @@ -115,12 +115,14 @@ public PipelineStageSummaryRS toStageSummaryRS(PipelineStage stage) { .parentStageId(stage.getParentStageId()) .status(stage.getStatus()) .statusCategory(stage.getStatusCategory()) - .origin(stage.getOrigin()) + .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(); } @@ -133,12 +135,14 @@ public PipelineStageRS toStageRS(PipelineStage stage) { .parentStageId(stage.getParentStageId()) .status(stage.getStatus()) .statusCategory(stage.getStatusCategory()) - .origin(stage.getOrigin()) + .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())) @@ -174,7 +178,7 @@ public PipelineStageAttemptRS toAttemptRS(PipelineStageAttempt attempt) { public PipelineStage toStageEntity(PipelineStageRQ rq) { var stage = new PipelineStage(); stage.setStageKey(rq.getStageKey()); - stage.setOrigin(StageOrigin.REPORTED); + stage.setOwnedBy(StageOwner.PIPELINE); applyStage(stage, rq); return stage; } @@ -201,10 +205,11 @@ public void applyStage(PipelineStage stage, PipelineStageRQ rq) { 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.setSummary(rq.getSummary()); stage.setMessage(rq.getMessage()); - if (rq.getRelatedEntities() != null) { - stage.setRelatedEntities(new PipelineRelatedEntities(rq.getRelatedEntities())); - } + 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.setDocument(rq.getDocument() == null ? null : new PipelineDocument(rq.getDocument())); stage.setDocumentSchemaName(rq.getDocumentSchemaName()); 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 index d3475aeeff..a0358b184b 100644 --- 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 @@ -41,6 +41,12 @@ public class PipelineAttribute implements Serializable { @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; @@ -53,7 +59,8 @@ public class PipelineAttribute implements Serializable { @Schema(description = "Link target, used with valueType LINK") private String url; - @Schema(description = "Position among attributes of the same type; unordered attributes keep the reported order") + @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") @@ -62,9 +69,10 @@ public class PipelineAttribute implements Serializable { @Schema(description = "Stage attributes only: sum this attribute over the stages into the iteration") private AttributeRollup rollup; - @Schema(description = "Marks what the owner is about (e.g. the requirement). Iterations whose identity " - + "attributes are equal cover the same scope and can be compared case by case") - private boolean identity; + @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 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 index 9129068aad..df4be4a748 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -37,6 +38,10 @@ public class PipelineSection implements Serializable { @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; @@ -45,6 +50,27 @@ public class PipelineSection implements Serializable { @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 java.util.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 @@ -78,6 +104,30 @@ public static class Row implements Serializable { @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; } /** 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 index 20818fab8f..9ca3939127 100644 --- 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 @@ -44,13 +44,14 @@ static List normalize(List reported) { .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()) - .identity(attribute.isIdentity()) + .comparisonKey(attribute.isComparisonKey()) .build(); byKey.put(copy.getKey(), copy); } @@ -139,8 +140,9 @@ static List rollup(List stages, Set re .key(template.getKey()) .label(template.getLabel()) .value(entry.getValue().stripTrailingZeros().toPlainString()) - .type(AttributeType.MAIN) + .type(AttributeType.META) .valueType(template.getValueType()) + .order(template.getOrder()) .direction(template.getDirection()) .build(); }) 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 index 9d73d93ba7..73e4c2bfe9 100644 --- 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 @@ -22,7 +22,7 @@ /** * 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 identity attributes. + * sections both iterations have, and "same scope" means equal comparison-key attributes. */ @Component public class PipelineComparator { @@ -43,17 +43,17 @@ public PipelineCompareRS compare(PipelineIterationDetailRS baseline, PipelineIte } private boolean sameScope(PipelineIterationDetailRS baseline, PipelineIterationDetailRS candidate) { - var baselineScope = identity(baseline); - var candidateScope = identity(candidate); + var baselineScope = comparisonKeys(baseline); + var candidateScope = comparisonKeys(candidate); return !baselineScope.isEmpty() && baselineScope.equals(candidateScope); } - private Set identity(PipelineIterationDetailRS iteration) { + private Set comparisonKeys(PipelineIterationDetailRS iteration) { if (iteration.getAttributes() == null) { return Set.of(); } return iteration.getAttributes().stream() - .filter(PipelineAttribute::isIdentity) + .filter(PipelineAttribute::isComparisonKey) .map(attribute -> attribute.getKey() + "=" + attribute.getValue()) .collect(Collectors.toSet()); } @@ -90,12 +90,14 @@ private PipelineStageSummaryRS summaryOf(PipelineStageRS stage) { .parentStageId(stage.getParentStageId()) .status(stage.getStatus()) .statusCategory(stage.getStatusCategory()) - .origin(stage.getOrigin()) + .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(); } 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 index 35520e024b..4d41d76b3f 100644 --- 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 @@ -62,7 +62,13 @@ public PipelineIteration refresh(PipelineIteration iteration) { .filter(PipelineStage::isAffectsIterationStatus) .map(PipelineStage::getStatusCategory) .filter(category -> category != StatusCategory.SKIPPED && category != StatusCategory.UNKNOWN) - .collect(Collectors.toSet()); + .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) : StatusCategory.aggregate(categories); iteration.setStatusCategory(category); iteration.setStatus(applicable.stream() 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 index 5bcb34c642..06d5b1aea5 100644 --- 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 @@ -9,7 +9,7 @@ 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.StageOrigin; +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; @@ -76,8 +76,8 @@ public PipelineIterationIngestResult createPipelineIteration(Long projectId, Lon validateStages(stageRQs); var pipeline = pipelineRepository - .findByProjectIdAndName(projectId, rq.getPipelineName()) - .orElseGet(() -> createPipeline(projectId, rq.getPipelineName(), rq.getPipelineType())); + .findByProjectIdAndName(projectId, rq.getPipeline().getName()) + .orElseGet(() -> createPipeline(projectId, rq.getPipeline().getName(), rq.getPipeline().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() + "'")); @@ -144,7 +144,7 @@ public PipelineIterationIngestResult createPipelineIteration(Long projectId, Lon var warnings = new ArrayList(); for (int i = 0; i < stageRQs.size(); i++) { var stage = savedStages.get(i); - warnings.addAll(resolveRelatedEntities(projectId, lockedPipeline, stage, stageRQs.get(i).getRelatedEntities())); + warnings.addAll(resolveRelatedEntities(projectId, lockedPipeline, stage, false)); notifyStageReportedHandlers(projectId, lockedPipeline, stage, stage.getStatusCategory() == StatusCategory.PASSED); } @@ -172,6 +172,7 @@ public PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, S PipelineStage stage; boolean becamePassed; + boolean hadRelatedEntities = false; if (existing == null) { rq.setStageKey(stageKey); stage = pipelineMapper.toStageEntity(rq); @@ -190,13 +191,14 @@ public PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, S becamePassed = stage.getStatusCategory() == StatusCategory.PASSED; } else { stage = existing; - if (stage.getOrigin() == StageOrigin.DERIVED) { + 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)); @@ -209,7 +211,7 @@ public PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, S becamePassed = previousCategory != StatusCategory.PASSED && stage.getStatusCategory() == StatusCategory.PASSED; } - var warnings = resolveRelatedEntities(projectId, iteration.getPipeline(), stage, rq.getRelatedEntities()); + var warnings = resolveRelatedEntities(projectId, iteration.getPipeline(), stage, hadRelatedEntities); notifyStageReportedHandlers(projectId, iteration.getPipeline(), stage, becamePassed); var refreshed = refresher.refresh(iteration); @@ -311,7 +313,7 @@ private void checkDocument(PipelineStage stage) { 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.MAIN).valueType(AttributeValueType.TEXT).build()); + .type(AttributeType.META).valueType(AttributeValueType.TEXT).build()); stage.setAttributes(new PipelineAttributes(attributes)); }); } @@ -329,7 +331,7 @@ private Pipeline createPipeline(Long projectId, String name, String type) { * type than the recorded one is rejected. */ private void applyPipelineDescription(Pipeline pipeline, PipelineIterationRQ rq) { - var reportedType = rq.getPipelineType(); + var reportedType = rq.getPipeline().getType(); if (reportedType != null && !reportedType.isBlank()) { if (pipeline.getType() == null) { pipeline.setType(reportedType); @@ -338,9 +340,9 @@ private void applyPipelineDescription(Pipeline pipeline, PipelineIterationRQ rq) "Pipeline '" + pipeline.getName() + "' is of type '" + pipeline.getType() + "', not '" + reportedType + "'"); } } - if (rq.getPipelineAttributes() != null && !rq.getPipelineAttributes().isEmpty()) { + if (rq.getPipeline().getAttributes() != null && !rq.getPipeline().getAttributes().isEmpty()) { pipeline.setAttributes(PipelineAttributeSupport.toColumn( - PipelineAttributeSupport.merge(pipeline.getAttributes(), rq.getPipelineAttributes(), null))); + PipelineAttributeSupport.merge(pipeline.getAttributes(), rq.getPipeline().getAttributes(), null))); } pipelineRepository.save(pipeline); } @@ -406,16 +408,19 @@ private PipelineStage resolveParent(Long iterationId, String parentStageKey, Pip } /** - * Hands the related entities a stage reported 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 are kept as reported. + * 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: a stage reports {@code [{entityType: "TEST_CASE", entityId: "TC-12"}]}; the resolver for - * {@code TEST_CASE} links the stage to test case TC-12. + * 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, - List relatedEntities) { - if (relatedEntities == null) { + boolean hadRelatedEntities) { + var relatedEntities = stage.getRelatedEntities() == null ? List.of() + : stage.getRelatedEntities().getItems(); + if (relatedEntities.isEmpty() && !hadRelatedEntities) { return List.of(); } var entityIdsByType = new LinkedHashMap>(); 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 index 4563dced6c..a2f9eecfd2 100644 --- 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 @@ -7,8 +7,7 @@ /** * Extension point for the things elsewhere in ReportPortal that a stage reports it worked on or produced. *

- * Example: a test case generation stage reports - * {@code relatedEntities: [{entityType: "TEST_CASE", entityId: "TC-12"}]}. The resolver whose + * 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. 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 index dc14cb6d85..736149f397 100644 --- 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 @@ -3,7 +3,7 @@ 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.StageOrigin; +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; @@ -101,7 +101,7 @@ private ResolvedStageForRetry claimStageForRetry(Long projectId, Long iterationI "Stage '" + stageId + "' has passed and cannot be retried"); } var execution = stage.getExecution() == null ? null : stage.getExecution().getContent(); - if (category == StatusCategory.UNKNOWN || stage.getOrigin() == StageOrigin.DERIVED + if (category == StatusCategory.UNKNOWN || stage.getOwnedBy() == StageOwner.REPORTPORTAL || execution == null || !execution.isRetryable()) { throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "Stage '" + stageId + "' is not retryable"); } 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 index a7b41be8b7..d7c539690d 100644 --- 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 @@ -43,10 +43,10 @@ public List onStageReported(StageReport report) { 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.MAIN).valueType(AttributeValueType.NUMBER) + .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.HIDDEN) + .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/TestCaseReviewContributor.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TestCaseReviewContributor.java index ec612d2ab7..fd2af35666 100644 --- 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 @@ -3,7 +3,7 @@ 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.StageOrigin; +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; @@ -43,7 +43,7 @@ public boolean supports(Pipeline pipeline) { 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.getOrigin() != StageOrigin.DERIVED) { + 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(); } @@ -68,6 +68,7 @@ public List contribute(PipelineIteration iteration, List contribute(PipelineIteration iteration, List iterationStatusLabel(StatusCategory category) { @Override public List iterationAttributes(PipelineIteration iteration, List stages) { return stages.stream() - .filter(stage -> REVIEW_KEY.equals(stage.getStageKey()) && stage.getOrigin() == StageOrigin.DERIVED) + .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.MAIN).valueType(AttributeValueType.NUMBER).build())) + .value(ready.getValue()).type(AttributeType.META).valueType(AttributeValueType.NUMBER).build())) .orElse(List.of()); } @@ -115,7 +116,7 @@ private PipelineStage newReviewStage(PipelineIteration iteration, 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/controller/PipelineControllerTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/PipelineControllerTest.java index 191bffe5ec..3080d70af1 100644 --- 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 @@ -15,6 +15,7 @@ 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.PipelineDefinitionRQ; 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; @@ -165,7 +166,7 @@ void getIterations_ShouldDelegateToServiceAndReturnPagedContent() throws Excepti @Test void ingestIteration_ShouldDelegateToServiceWithUserIdAndReturnCreated() throws Exception { var rq = PipelineIterationRQ.builder() - .pipelineName("factory-tc-gen") + .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(PipelineStageRQ.builder() .stageKey("stage-gen-tc").sequence(1).status("Passed").build())) .build(); @@ -186,7 +187,7 @@ void ingestIteration_ShouldDelegateToServiceWithUserIdAndReturnCreated() throws @Test void ingestIteration_WithIdempotencyKey_ShouldPassKeyAndReturn200WithReplayHeaderOnReplay() throws Exception { var rq = PipelineIterationRQ.builder() - .pipelineName("factory-tc-gen") + .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(PipelineStageRQ.builder() .stageKey("stage-gen-tc").sequence(1).status("Passed").build())) .build(); 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 index e747a366fc..ca9b4cca71 100644 --- 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 @@ -19,6 +19,8 @@ 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.PipelineDefinitionRQ; 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; @@ -100,7 +102,7 @@ private JsonNode body(org.springframework.test.web.servlet.ResultActions actions } private static PipelineAttribute main(String key, String value) { - return PipelineAttribute.builder().key(key).value(value).type(AttributeType.MAIN).build(); + return PipelineAttribute.builder().key(key).value(value).type(AttributeType.META).build(); } // ------------------------------------------------------------------ pipelines @@ -164,7 +166,7 @@ void getIterations_ShouldReturnBothIterationsWithTypedAttributesAndCategories() assertEquals("WAITING", newest.get("statusCategory").asText()); assertEquals("US-TMS-EXP-002", attribute(newest, "requirement")); assertEquals("beta5", attribute(oldest, "env")); - assertEquals("MAIN", oldest.get("attributes").get(0).get("type").asText()); + 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()); } @@ -216,15 +218,14 @@ void getPipelines_WithSearch_ShouldMatchPipelineNameOrAnyOfItsIterations() throw @Test void createIteration_ForNewPipelineName_ShouldCreatePipelineWithTypeAndLinkTestCases() throws Exception { var rq = PipelineIterationRQ.builder() - .pipelineName("brand-new-pipeline") - .pipelineType("TEST_AUTOMATION") - .pipelineAttributes(List.of(main("repo", "org/auto"))) + .pipeline(PipelineDefinitionRQ.builder().name("brand-new-pipeline").type("TEST_AUTOMATION") + .attributes(List.of(main("repo", "org/auto"))).build()) .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())) - .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), new PipelineSection.EntityReference("TEST_CASE", "TC-404"))) + .sections(PipelineTestData.sectionLinking(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), new PipelineSection.EntityReference("TEST_CASE", "TC-404")))) .build())) .build(); @@ -249,7 +250,7 @@ void createIteration_ForNewPipelineName_ShouldCreatePipelineWithTypeAndLinkTestC @Test void createIteration_WithADifferentPipelineTypeThanRecorded_ShouldBeBadRequest() throws Exception { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").pipelineType("TEST_AUTOMATION").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("factory-tc-gen").type("TEST_AUTOMATION").build()).build(); mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -261,11 +262,11 @@ void createIteration_WithADifferentPipelineTypeThanRecorded_ShouldBeBadRequest() @Test void createIteration_WithIdempotencyKey_ShouldReplaySameBodyAndRejectDifferentBody() throws Exception { var rq = PipelineIterationRQ.builder() - .pipelineName("factory-tc-gen") + .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Passed").build())) .build(); var other = PipelineIterationRQ.builder() - .pipelineName("factory-tc-gen") + .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Failed").build())) .build(); var url = BASE + "/iteration"; @@ -298,7 +299,7 @@ void createIteration_WithIdempotencyKey_ShouldReplaySameBodyAndRejectDifferentBo @Test void createIteration_ForExistingPipelineNameWithRerunOf_ShouldIncrementNumberAndLinkTheSource() throws Exception { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").rerunOfIterationId(901L).build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).rerunOfIterationId(901L).build(); mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -314,7 +315,7 @@ void createIteration_ForExistingPipelineNameWithRerunOf_ShouldIncrementNumberAnd @Test void createIteration_WithNestedStageSectionsAndResultPointer_ShouldRoundTripTreeTablesAndResult() throws Exception { var rq = PipelineIterationRQ.builder() - .pipelineName("brand-new-automation-pipeline") + .pipeline(PipelineDefinitionRQ.named("brand-new-automation-pipeline")) .stages(List.of( PipelineStageRQ.builder() .stageKey("automate") @@ -358,7 +359,7 @@ void createIteration_WithNestedStageSectionsAndResultPointer_ShouldRoundTripTree @Test void createIteration_WithBareScalarCells_ShouldAcceptThem() throws Exception { var json = """ - {"pipelineName": "scalar-cells", + {"pipeline": {"name": "scalar-cells"}, "stages": [{"stageKey": "grade", "status": "Passed", "sections": [{"key": "grades", "columns": [{"key": "score", "valueType": "SCORE"}], "rows": [{"key": "r1", "cells": {"score": 90}}]}]}]} @@ -378,7 +379,7 @@ void createIteration_WithBareScalarCells_ShouldAcceptThem() throws Exception { void reportStage_ShouldReportAStageStepByStepAndKeepTheIterationStatusInStep() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipelineName("incremental").build())) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("incremental")).build())) .with(token(oAuthHelper.getSuperadminToken()))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.statusCategory").value("PENDING"))); @@ -406,7 +407,7 @@ void reportStage_ShouldReportAStageStepByStepAndKeepTheIterationStatusInStep() t void reportStage_OfAFinishedStageByAnotherJob_ShouldStartANewAttemptAndKeepTheFinishedOne() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipelineName("attempts").build())) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("attempts")).build())) .with(token(oAuthHelper.getSuperadminToken())))); var url = BASE + "/iteration/" + created.get("id").asLong() + "/stage/upload"; @@ -433,7 +434,7 @@ private JsonNode report(String url, String status, String jobId) throws Exceptio void reportStage_ForAStageWithAnUnknownStatusText_ShouldKeepTheTextAndShowItNeutrally() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipelineName("unknown-status").build())) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("unknown-status")).build())) .with(token(oAuthHelper.getSuperadminToken())))); mockMvc.perform(put(BASE + "/iteration/" + created.get("id").asLong() + "/stage/grade") @@ -452,13 +453,13 @@ void generationPipeline_ShouldGetAReviewStageMaintainedByReportPortalAndFailedGr draft.setStatus(TmsTestCaseStatus.DRAFT); tmsTestCaseRepository.saveAndFlush(draft); var rq = PipelineIterationRQ.builder() - .pipelineName("factory-tc-gen") + .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .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") - .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), - new PipelineSection.EntityReference("TEST_CASE", "TC-901"))).build())) + .sections(PipelineTestData.sectionLinking(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-900"), + new PipelineSection.EntityReference("TEST_CASE", "TC-901")))).build())) .build(); var detail = body(mockMvc.perform(post(BASE + "/iteration") @@ -471,7 +472,7 @@ void generationPipeline_ShouldGetAReviewStageMaintainedByReportPortalAndFailedGr var review = stage(detail, "Review"); assertNotNull(review); - assertEquals("DERIVED", review.get("origin").asText()); + assertEquals("REPORTPORTAL", review.get("ownedBy").asText()); assertEquals("In progress", review.get("status").asText()); assertEquals("1/2", attribute(review, "review")); } @@ -480,7 +481,7 @@ void generationPipeline_ShouldGetAReviewStageMaintainedByReportPortalAndFailedGr void reportStage_ForTheReviewStageMaintainedByReportPortal_ShouldBeRejected() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build())) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build())) .with(token(oAuthHelper.getSuperadminToken())))); mockMvc.perform(put(BASE + "/iteration/" + created.get("id").asLong() + "/stage/review") 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 index 2c158a0465..ce2bcdd843 100644 --- 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 @@ -49,7 +49,7 @@ 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", "origin"); + assertRequired("PipelineStageRS", "id", "name", "sequence", "status", "statusCategory", "ownedBy"); assertRequired("PipelineStageRetryRS", "stageId", "status", "statusCategory"); assertRequired("TmsQualityStandardRS", "id", "name", "criteria"); assertRequired("TmsQualityStandardCriterionRS", "id", "name", "maxPoints", "sequence"); 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 index ddad9f2712..1f48aa3f06 100644 --- 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 @@ -5,6 +5,7 @@ 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.PipelineIterationSummaryRS; import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; @@ -12,7 +13,7 @@ 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.StageOrigin; +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; @@ -91,7 +92,7 @@ void toPipelineRS_ShouldMapAllFields() { @Test void toIterationSummaryRS_ShouldMapHeaderFactsAndGivenAttributes() { var attributes = List.of(PipelineAttribute.builder().key("requirement").value("US-1") - .type(AttributeType.MAIN).build()); + .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(); @@ -160,9 +161,9 @@ void toStageSummaryRS_ShouldMapStatusAsReportedAndAttributesButNotHeavyDetails() stage.setParentStageId(40L); stage.setStatus("Queued · waiting for runner"); stage.setStatusCategory(StatusCategory.UNKNOWN); - stage.setOrigin(StageOrigin.REPORTED); + stage.setOwnedBy(StageOwner.PIPELINE); stage.setAttributes(new PipelineAttributes(List.of( - PipelineAttribute.builder().key("score").value("90").type(AttributeType.MAIN).build()))); + 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); @@ -221,10 +222,12 @@ void stageTiming_ShouldFlowFromRequestToEntityAndBothResponseShapes() { } @Test - void relatedEntities_ShouldBeKeptAsReportedAndReturnedOnTheStage() { + void relatedEntities_ShouldBeCollectedFromTheCellTargetsOfTheSectionsAndReturnedOnTheStage() { var rq = PipelineStageRQ.builder().stageKey("upload").status("Passed") - .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-12"), - new PipelineSection.EntityReference("LAUNCH", "7"))) + .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); @@ -237,13 +240,25 @@ void relatedEntities_ShouldBeKeptAsReportedAndReturnedOnTheStage() { } @Test - void relatedEntities_WhenTheReportLeavesThemOut_ShouldKeepTheEarlierOnes() { + void relatedEntities_WhenTheReportNoLongerLinksAnything_ShouldClearThem() { var stage = mapper.toStageEntity(PipelineStageRQ.builder().stageKey("upload").status("Running") - .relatedEntities(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-12"))).build()); + .sections(PipelineTestData.sectionLinking(List.of(new PipelineSection.EntityReference("TEST_CASE", "TC-12")))) + .build()); mapper.applyStage(stage, PipelineStageRQ.builder().status("Passed").build()); - assertEquals("TC-12", stage.getRelatedEntities().getItems().get(0).getEntityId()); + 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 @@ -284,14 +299,14 @@ void toStageRS_WithoutCiOrResult_ShouldLeaveThemNull() { stage.setSequence(2); stage.setStatus("In progress"); stage.setStatusCategory(StatusCategory.WAITING); - stage.setOrigin(StageOrigin.DERIVED); + stage.setOwnedBy(StageOwner.REPORTPORTAL); var result = mapper.toStageRS(stage); assertEquals(50L, result.getId()); assertEquals("In progress", result.getStatus()); assertEquals(StatusCategory.WAITING, result.getStatusCategory()); - assertEquals(StageOrigin.DERIVED, result.getOrigin()); + assertEquals(StageOwner.REPORTPORTAL, result.getOwnedBy()); assertNull(result.getLinks()); assertNull(result.getResult()); assertNull(result.getParentStageId()); @@ -361,7 +376,7 @@ void toStageEntity_WithExecution_ShouldKeepItAsReportedAlongWithTheRest() { assertEquals(2, stage.getSequence()); assertEquals("Passed", stage.getStatus()); assertEquals(StatusCategory.PASSED, stage.getStatusCategory()); - assertEquals(StageOrigin.REPORTED, stage.getOrigin()); + assertEquals(StageOwner.PIPELINE, stage.getOwnedBy()); assertFalse(stage.isAffectsIterationStatus()); assertEquals("agent", stage.getAttributes().getItems().get(0).getKey()); assertEquals("rows", stage.getSections().getItems().get(0).getKey()); 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 index 5c7753f6d4..9237c4f88f 100644 --- 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 @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.epam.reportportal.base.core.aifactory.dto.PipelineDefinitionRQ; import com.epam.reportportal.base.core.aifactory.dto.PipelineIterationRQ; import com.epam.reportportal.base.core.aifactory.dto.PipelineStageRQ; import java.util.LinkedHashMap; @@ -14,7 +15,7 @@ class IngestFingerprintTest { private PipelineIterationRQ rq(Map document, String status) { - return PipelineIterationRQ.builder().pipelineName("p") + return PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("p")) .stages(List.of(PipelineStageRQ.builder().stageKey("s").sequence(1).status(status).document(document).build())) .build(); } 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 index 98769dea12..33089b7e3d 100644 --- 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 @@ -105,10 +105,29 @@ void rollup_ShouldSumFlaggedStageAttributesExceptKeysTheIterationAlreadyHas() { assertEquals("cost", result.get(0).getKey()); assertEquals("Cost", result.get(0).getLabel()); assertEquals("0.96", result.get(0).getValue()); - assertEquals(AttributeType.MAIN, result.get(0).getType()); + 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()); 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 index d08f9a6cda..a84840bffb 100644 --- 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 @@ -24,12 +24,12 @@ class PipelineComparatorTest { private final PipelineComparator comparator = new PipelineComparator(); private static PipelineAttribute requirement(String value) { - return PipelineAttribute.builder().key("requirement").value(value).type(AttributeType.MAIN).identity(true).build(); + 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.MAIN).valueType(type) + return PipelineAttribute.builder().key(key).label(key).value(value).type(AttributeType.META).valueType(type) .direction(direction).build(); } 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 index 8b39b7890f..3e8df54d1a 100644 --- 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 @@ -62,6 +62,26 @@ private void givenStages(PipelineStage... 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_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), @@ -140,7 +160,7 @@ void refresh_ShouldAddIterationAttributesContributedByTheStages() { 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.MAIN) + .type(com.epam.reportportal.base.core.aifactory.enums.AttributeType.META) .valueType(com.epam.reportportal.base.core.aifactory.enums.AttributeValueType.NUMBER).build())); refresher.refresh(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 index 736624e566..c7fbda78ed 100644 --- 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 @@ -12,6 +12,8 @@ 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.PipelineDefinitionRQ; 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; @@ -19,7 +21,7 @@ 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.StageOrigin; +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; @@ -160,7 +162,8 @@ void setUp() { } private static PipelineStageRQ stageRQ(String stageKey, String status, List refs) { - return PipelineStageRQ.builder().stageKey(stageKey).status(status).relatedEntities(refs).build(); + return PipelineStageRQ.builder().stageKey(stageKey).status(status) + .sections(refs == null ? null : PipelineTestData.sectionLinking(refs)).build(); } private PipelineIteration persistedIteration(long id) { @@ -184,7 +187,7 @@ private PipelineStage persistedStage(long iterationId, String key, String status stage.setAttempt(1); stage.setStatus(status); stage.setStatusCategory(category); - stage.setOrigin(StageOrigin.REPORTED); + stage.setOwnedBy(StageOwner.PIPELINE); pipelineIterationRepository.findById(iterationId).ifPresent(stage::setIteration); when(pipelineStageRepository.findByIterationIdAndStageKey(iterationId, key)).thenReturn(Optional.of(stage)); return stage; @@ -208,7 +211,7 @@ void create_NewPipelineFirstIteration_ShouldCreatePipelineWithTypeAndNumberOne() when(pipelineRepository.findByIdForUpdate(3L)).thenAnswer(inv -> Optional.of(created)); when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(3L)) .thenReturn(Optional.empty()); - var rq = PipelineIterationRQ.builder().pipelineName("new-pipeline").pipelineType("TEST_CASE_GENERATION") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("new-pipeline").type("TEST_CASE_GENERATION").build()) .stages(List.of(stageRQ("create", "Passed", null))).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -228,7 +231,7 @@ void create_ExistingPipelineWithPriorIterations_ShouldIncrementIterationNumber() previous.setIterationNumber(3); when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipeline.getId())) .thenReturn(Optional.of(previous)); - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -240,8 +243,9 @@ void create_ExistingPipelineWithPriorIterations_ShouldIncrementIterationNumber() @Test void create_ShouldRecordPipelineTypeTheFirstTimeAndMergePipelineAttributes() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").pipelineType("TEST_CASE_GENERATION") - .pipelineAttributes(List.of(PipelineAttribute.builder().key("repo").value("org/repo").build())).build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("factory-tc-gen") + .type("TEST_CASE_GENERATION") + .attributes(List.of(PipelineAttribute.builder().key("repo").value("org/repo").build())).build()).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -252,7 +256,7 @@ void create_ShouldRecordPipelineTypeTheFirstTimeAndMergePipelineAttributes() { @Test void create_WithADifferentTypeThanRecorded_ShouldBeRejected() { pipeline.setType("TEST_AUTOMATION"); - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").pipelineType("TEST_CASE_GENERATION").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("factory-tc-gen").type("TEST_CASE_GENERATION").build()).build(); var ex = assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); @@ -262,7 +266,7 @@ void create_WithADifferentTypeThanRecorded_ShouldBeRejected() { @Test void create_WithoutStages_ShouldBeAllowedAndRefreshTheDerivedStatus() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); var result = sut.createPipelineIteration(projectId, userId, rq, null); @@ -273,7 +277,7 @@ void create_WithoutStages_ShouldBeAllowedAndRefreshTheDerivedStatus() { @Test void create_WithAttributes_ShouldStoreNormalizedAttributesSearchTextAndIndex() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .attributes(List.of( PipelineAttribute.builder().key("requirement").value("US-TMS-EXP-002").build(), PipelineAttribute.builder().key("env").value("beta5").build())) @@ -291,7 +295,7 @@ void create_WithAttributes_ShouldStoreNormalizedAttributesSearchTextAndIndex() { @Test void create_WithoutAttributes_ShouldLeaveThemNull() { - sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build()); + sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build()); var captor = ArgumentCaptor.forClass(PipelineIteration.class); verify(pipelineIterationRepository).save(captor.capture()); @@ -301,7 +305,7 @@ void create_WithoutAttributes_ShouldLeaveThemNull() { @Test void create_WithStages_ShouldSaveThemWithAttemptOneAndSequenceFromPositionWhenAbsent() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("create", "Passed", null), stageRQ("grade", "Running", null))).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -318,7 +322,7 @@ void create_WithStages_ShouldSaveThemWithAttemptOneAndSequenceFromPositionWhenAb @Test void create_WithNestedStage_ShouldAssignParentStageIdAndPath() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of( PipelineStageRQ.builder().stageKey("parent").status("Passed").build(), PipelineStageRQ.builder().stageKey("child").status("Passed").parentStageKey("parent").build())) @@ -340,7 +344,7 @@ void create_WithNestedStage_ShouldAssignParentStageIdAndPath() { @Test void create_WithUnknownParentStageKey_ShouldThrowBadRequest() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(PipelineStageRQ.builder().stageKey("child").status("Passed") .parentStageKey("missing-parent").build())) .build(); @@ -350,7 +354,7 @@ void create_WithUnknownParentStageKey_ShouldThrowBadRequest() { @Test void create_WithCycleInParentChain_ShouldThrowBadRequest() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of( PipelineStageRQ.builder().stageKey("a").status("Passed").parentStageKey("b").build(), PipelineStageRQ.builder().stageKey("b").status("Passed").parentStageKey("a").build())) @@ -361,9 +365,9 @@ void create_WithCycleInParentChain_ShouldThrowBadRequest() { @Test void create_WithDuplicateOrBlankStageKeys_ShouldThrowBadRequest() { - var duplicate = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var duplicate = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("a", "Passed", null), stageRQ("a", "Passed", null))).build(); - var blank = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var blank = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ(" ", "Passed", null))).build(); assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, duplicate)); @@ -373,7 +377,7 @@ void create_WithDuplicateOrBlankStageKeys_ShouldThrowBadRequest() { @Test void create_WithOversizedReport_ShouldBeRejected() { var document = Map.of("blob", "x".repeat((int) PipelineIterationServiceImpl.MAX_REPORT_BYTES)); - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(PipelineStageRQ.builder().stageKey("grade").status("Passed").document(document).build())) .build(); @@ -384,7 +388,7 @@ void create_WithOversizedReport_ShouldBeRejected() { @Test void create_RerunOfAnIterationOfTheSamePipeline_ShouldCreateNewIterationLinkedToTheSource() { var source = persistedIteration(99L); - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").rerunOfIterationId(99L).build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).rerunOfIterationId(99L).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -403,7 +407,7 @@ void create_RerunOfAnIterationOfAnotherPipeline_ShouldThrowNotFound() { source.setId(99L); source.setPipeline(other); when(pipelineIterationRepository.findById(99L)).thenReturn(Optional.of(source)); - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").rerunOfIterationId(99L).build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).rerunOfIterationId(99L).build(); var ex = assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); @@ -413,7 +417,7 @@ void create_RerunOfAnIterationOfAnotherPipeline_ShouldThrowNotFound() { @Test void create_ShouldPublishIngestedEventAfterRefreshing() { - sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build()); + sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build()); verify(eventPublisher).publishEvent(any(com.epam.reportportal.base.core.aifactory.event.PipelineIterationIngestedEvent.class)); } @@ -422,7 +426,7 @@ void create_ShouldPublishIngestedEventAfterRefreshing() { @Test void create_WithBlankOrTooLongIdempotencyKey_ShouldThrowBadRequest() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq, " ")); assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq, @@ -431,7 +435,7 @@ void create_WithBlankOrTooLongIdempotencyKey_ShouldThrowBadRequest() { @Test void create_ReplayOfTheSameKeyAndBody_ShouldAnswerWithTheFirstIterationAndCreateNothing() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); var previous = new PipelineIteration(); previous.setId(321L); previous.setIdempotencyHash(IngestFingerprint.of(rq)); @@ -448,7 +452,7 @@ void create_ReplayOfTheSameKeyAndBody_ShouldAnswerWithTheFirstIterationAndCreate @Test void create_SameKeyWithDifferentBody_ShouldThrowIdempotencyConflict() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); var previous = new PipelineIteration(); previous.setIdempotencyHash("another-hash"); when(pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(pipeline.getId(), "key-1")) @@ -462,7 +466,7 @@ void create_SameKeyWithDifferentBody_ShouldThrowIdempotencyConflict() { @Test void create_WithNewKey_ShouldStoreKeyAndFingerprintOnTheIteration() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen").build(); + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); when(pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(pipeline.getId(), "key-1")) .thenReturn(Optional.empty()); @@ -485,7 +489,7 @@ private static List relatedTestCases(String... 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().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("upload", "Passed", relatedTestCases("TC-1", "TC-404")))).build(); var detail = PipelineIterationDetailRS.builder().build(); when(viewAssembler.detail(any())).thenReturn(detail); @@ -501,7 +505,7 @@ void create_WithStageRefs_ShouldHandThemToTheResolverAndWarnAboutTheUnresolvedOn @Test void create_ShouldTellTheHandlersWhetherEachStagePassed() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("create", "Passed", null), stageRQ("upload", "Running", null))).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -517,7 +521,7 @@ void create_ShouldTellTheHandlersWhetherEachStagePassed() { 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().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("upload", "Passed", null))).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -530,7 +534,7 @@ void create_WithAHandlerAnswer_ShouldAddItsAttributesToTheStage() { @Test void create_WithRelatedEntitiesOfATypeNothingHandles_ShouldKeepThemWithoutWarning() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("upload", "Passed", List.of(new PipelineSection.EntityReference("LAUNCH", "12"))))) .build(); var detail = PipelineIterationDetailRS.builder().build(); @@ -548,7 +552,7 @@ void create_WithRelatedEntitiesOfATypeNothingHandles_ShouldKeepThemWithoutWarnin @Test void create_WithoutRefs_ShouldNotCallTheResolvers() { - var rq = PipelineIterationRQ.builder().pipelineName("factory-tc-gen") + var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) .stages(List.of(stageRQ("upload", "Passed", null))).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -676,7 +680,7 @@ void reportStage_WithRelatedEntities_ShouldHandThemToTheResolver() { persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running") - .relatedEntities(relatedTestCases("TC-1")).build()); + .sections(PipelineTestData.sectionLinking(relatedTestCases("TC-1"))).build()); verify(testCaseResolver).resolve(eq(projectId), eq(pipeline), any(), eq(List.of("TC-1"))); } @@ -697,12 +701,13 @@ void reportStage_ShouldSignalAPassToTheHandlersOnlyWhenTheStageBecomesPassed() { } @Test - void reportStage_WithEmptyRefs_ShouldLetTheResolverClearTheStage() { + void reportStage_WhenTheStageWasRelatedAndTheReportLinksNothing_ShouldLetTheResolverClearIt() { persistedIteration(10L); - persistedStage(10L, "upload", "Running", StatusCategory.RUNNING); + 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") - .relatedEntities(List.of()).build()); + sut.reportStage(projectId, 10L, "upload", PipelineStageRQ.builder().status("Running").build()); verify(testCaseResolver).resolve(eq(projectId), eq(pipeline), any(), eq(List.of())); } @@ -746,7 +751,7 @@ void reportStage_ForIterationOfAnotherProject_ShouldThrowNotFound() { @Test void reportStage_ForSystemManagedStage_ShouldBeRejected() { persistedIteration(10L); - persistedStage(10L, "review", "In progress", StatusCategory.WAITING).setOrigin(StageOrigin.DERIVED); + persistedStage(10L, "review", "In progress", StatusCategory.WAITING).setOwnedBy(StageOwner.REPORTPORTAL); assertThrows(ReportPortalException.class, () -> sut.reportStage(projectId, 10L, "review", stageRQ(null, "Done", null))); 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 index b72a1db63b..2e40121600 100644 --- 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 @@ -12,7 +12,7 @@ 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.StageOrigin; +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; @@ -167,7 +167,7 @@ void retryStage_WhenStatusUnknownOrStageMaintainedByTheSystem_ShouldNotBeRetryab assertThrows(ReportPortalException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); stage.setStatusCategory(StatusCategory.FAILED); - stage.setOrigin(StageOrigin.DERIVED); + stage.setOwnedBy(StageOwner.REPORTPORTAL); assertThrows(ReportPortalException.class, () -> sut.retryPipelineIterationStage(1L, 9L, 3L, 4L, null)); verify(githubConnector, never()).triggerStageRerun(any(), any()); 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 index 04fa2fef02..e7ecf6c275 100644 --- 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 @@ -9,7 +9,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -import com.epam.reportportal.base.core.aifactory.enums.StageOrigin; +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; @@ -49,7 +49,7 @@ private static PipelineStage stage(String key, int sequence, StatusCategory cate stage.setSequence(sequence); stage.setStatus(category.defaultLabel()); stage.setStatusCategory(category); - stage.setOrigin(StageOrigin.REPORTED); + stage.setOwnedBy(StageOwner.PIPELINE); return stage; } @@ -101,7 +101,7 @@ void contribute_BeforeUploadPassed_ShouldCreatePendingReviewThatDoesNotCount() { assertEquals("review", review.getStageKey()); assertEquals("Review", review.getName()); assertEquals(4, review.getSequence()); - assertEquals(StageOrigin.DERIVED, review.getOrigin()); + assertEquals(StageOwner.REPORTPORTAL, review.getOwnedBy()); assertEquals(StatusCategory.PENDING, review.getStatusCategory()); assertFalse(review.isAffectsIterationStatus()); assertSame(iteration, review.getIteration()); @@ -124,6 +124,7 @@ void contribute_WithUploadPassedAndDraftCases_ShouldBeInProgressWithReadyOverTot 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()); @@ -188,7 +189,7 @@ void contribute_WhenAllCasesReady_ShouldBeDoneAndFinished() { @Test void contribute_WhenACaseGoesBackToDraft_ShouldReopenAnExistingReviewStage() { var existing = stage("review", 4, StatusCategory.PASSED); - existing.setOrigin(StageOrigin.DERIVED); + 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))) diff --git a/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql b/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql index 01ebdc5a74..bd040f7842 100644 --- a/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql +++ b/src/test/resources/db/aifactory/pipeline/pipeline-fill.sql @@ -13,7 +13,7 @@ values (901, 'Pipeline Second Test Case', 'Another linked test case', 900, 'MEDI -- 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": "MAIN", "valueType": "TEXT"}]', + '[{"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) @@ -30,10 +30,10 @@ values (902, 1, 'other-pipeline-same-project', 'A different pipeline definition, 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": "MAIN", "valueType": "TEXT", "identity": true}, - {"key": "trigger", "label": "Trigger", "value": "CI - booking pack", "type": "MAIN", "valueType": "TEXT"}, - {"key": "suiteScore", "label": "Suite score", "value": "86", "type": "MAIN", "valueType": "SCORE", "direction": "HIGHER_BETTER"}, - {"key": "cost", "label": "Cost", "value": "0.61", "type": "MAIN", "valueType": "MONEY", "direction": "LOWER_BETTER"}, + '[{"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', @@ -43,10 +43,10 @@ values (900, 900, 1, 'Completed', 'PASSED', 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": "MAIN", "valueType": "TEXT", "identity": true}, - {"key": "trigger", "label": "Trigger", "value": "CI - booking pack retry", "type": "MAIN", "valueType": "TEXT"}, - {"key": "suiteScore", "label": "Suite score", "value": "72", "type": "MAIN", "valueType": "SCORE", "direction": "HIGHER_BETTER"}, - {"key": "cost", "label": "Cost", "value": "0.98", "type": "MAIN", "valueType": "MONEY", "direction": "LOWER_BETTER"}]', + '[{"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'); @@ -62,7 +62,7 @@ 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": "MAIN", "valueType": "NUMBER"}, + '[{"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"}], From 440b380448e9a316c7150edbaa558ac9db5abebf Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 18:15:33 +0300 Subject: [PATCH 03/12] Pipelines implementation --- .../base/core/aifactory/dto/IterationRQ.java | 42 ++++++++++ .../aifactory/dto/PipelineDefinitionRQ.java | 49 ----------- .../aifactory/dto/PipelineIterationRQ.java | 46 ++++++---- .../core/aifactory/mapper/PipelineMapper.java | 1 - .../core/aifactory/model/PipelineSection.java | 3 +- .../service/PipelineIterationServiceImpl.java | 25 +++--- .../controller/PipelineControllerTest.java | 16 ++-- .../integration/PipelineIntegrationTest.java | 53 ++++-------- .../service/IngestFingerprintTest.java | 6 +- .../PipelineIterationServiceImplTest.java | 84 +++++++------------ 10 files changed, 143 insertions(+), 182 deletions(-) create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/IterationRQ.java delete mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java 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/PipelineDefinitionRQ.java b/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java deleted file mode 100644 index 949c72b3c1..0000000000 --- a/src/main/java/com/epam/reportportal/base/core/aifactory/dto/PipelineDefinitionRQ.java +++ /dev/null @@ -1,49 +0,0 @@ -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.Size; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Which pipeline an iteration belongs to, and what is known about the pipeline itself. 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"}]}}. The attributes - * describe the pipeline (its repository); the iteration's own attributes (requirement, model, environment) - * are sent next to this object, not inside it. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PipelineDefinitionRQ { - - @NotBlank - @Size(max = 256) - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "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; - - public static PipelineDefinitionRQ named(String name) { - return PipelineDefinitionRQ.builder().name(name).build(); - } -} 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 index 89c6ae933a..ec3a1475a2 100644 --- 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 @@ -3,38 +3,52 @@ 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 java.time.Instant; +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 { - @NotNull - @Valid - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "The pipeline this iteration belongs to") - private PipelineDefinitionRQ pipeline; + @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 iteration: requirement, trigger, model, environment, links, tags, ...") + @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; - 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; - + @NotNull @Valid - @Schema(description = "Stages known at creation time; may be empty - stages can be reported one by one afterwards") - private List stages; + @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/mapper/PipelineMapper.java b/src/main/java/com/epam/reportportal/base/core/aifactory/mapper/PipelineMapper.java index 4affba9d6d..9057fcea89 100644 --- 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 @@ -206,7 +206,6 @@ public void applyStage(PipelineStage stage, PipelineStageRQ rq) { 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.setSummary(rq.getSummary()); stage.setMessage(rq.getMessage()); var related = PipelineSection.entityReferences(rq.getSections()); stage.setRelatedEntities(related.isEmpty() ? null : new PipelineRelatedEntities(related)); 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 index df4be4a748..3b7279909f 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -56,7 +57,7 @@ public class PipelineSection implements Serializable { * 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 java.util.LinkedHashSet(); + var references = new LinkedHashSet(); if (sections != null) { for (var section : sections) { for (var row : section.getRows() == null ? List.of() : section.getRows()) { 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 index 06d5b1aea5..8dc96c388a 100644 --- 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 @@ -72,12 +72,13 @@ public class PipelineIterationServiceImpl implements PipelineIterationService { public PipelineIterationIngestResult createPipelineIteration(Long projectId, Long userId, PipelineIterationRQ rq, String idempotencyKey) { validateSize(rq); - var stageRQs = rq.getStages() == null ? List.of() : rq.getStages(); + var run = rq.getIteration(); + var stageRQs = run.getStages() == null ? List.of() : run.getStages(); validateStages(stageRQs); var pipeline = pipelineRepository - .findByProjectIdAndName(projectId, rq.getPipeline().getName()) - .orElseGet(() -> createPipeline(projectId, rq.getPipeline().getName(), rq.getPipeline().getType())); + .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() + "'")); @@ -105,11 +106,11 @@ public PipelineIterationIngestResult createPipelineIteration(Long projectId, Lon .map(PipelineIteration::getIterationNumber) .orElse(0) + 1); iteration.setCreatedBy(userId); - if (rq.getRerunOfIterationId() != null) { - var source = pipelineIterationRepository.findById(rq.getRerunOfIterationId()) + 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 '" + rq.getRerunOfIterationId() + "' for pipeline '" + "Pipeline iteration '" + run.getRerunOfIterationId() + "' for pipeline '" + lockedPipeline.getName() + "'")); iteration.setRerun(true); iteration.setRerunOfIterationId(source.getId()); @@ -118,9 +119,9 @@ public PipelineIterationIngestResult createPipelineIteration(Long projectId, Lon iteration.setIdempotencyKey(idempotencyKey); iteration.setIdempotencyHash(fingerprint); } - iteration.setStartedAt(rq.getStartedAt()); - iteration.setFinishedAt(rq.getFinishedAt()); - setAttributes(iteration, PipelineAttributeSupport.normalize(rq.getAttributes())); + 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); @@ -331,7 +332,7 @@ private Pipeline createPipeline(Long projectId, String name, String type) { * type than the recorded one is rejected. */ private void applyPipelineDescription(Pipeline pipeline, PipelineIterationRQ rq) { - var reportedType = rq.getPipeline().getType(); + var reportedType = rq.getType(); if (reportedType != null && !reportedType.isBlank()) { if (pipeline.getType() == null) { pipeline.setType(reportedType); @@ -340,9 +341,9 @@ private void applyPipelineDescription(Pipeline pipeline, PipelineIterationRQ rq) "Pipeline '" + pipeline.getName() + "' is of type '" + pipeline.getType() + "', not '" + reportedType + "'"); } } - if (rq.getPipeline().getAttributes() != null && !rq.getPipeline().getAttributes().isEmpty()) { + if (rq.getAttributes() != null && !rq.getAttributes().isEmpty()) { pipeline.setAttributes(PipelineAttributeSupport.toColumn( - PipelineAttributeSupport.merge(pipeline.getAttributes(), rq.getPipeline().getAttributes(), null))); + PipelineAttributeSupport.merge(pipeline.getAttributes(), rq.getAttributes(), null))); } pipelineRepository.save(pipeline); } 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 index 3080d70af1..7e69ad70d3 100644 --- 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 @@ -15,7 +15,7 @@ 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.PipelineDefinitionRQ; +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; @@ -165,11 +165,8 @@ void getIterations_ShouldDelegateToServiceAndReturnPagedContent() throws Excepti @Test void ingestIteration_ShouldDelegateToServiceWithUserIdAndReturnCreated() throws Exception { - var rq = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(PipelineStageRQ.builder() - .stageKey("stage-gen-tc").sequence(1).status("Passed").build())) - .build(); + 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)); @@ -186,11 +183,8 @@ void ingestIteration_ShouldDelegateToServiceWithUserIdAndReturnCreated() throws @Test void ingestIteration_WithIdempotencyKey_ShouldPassKeyAndReturn200WithReplayHeaderOnReplay() throws Exception { - var rq = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(PipelineStageRQ.builder() - .stageKey("stage-gen-tc").sequence(1).status("Passed").build())) - .build(); + 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)); 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 index ca9b4cca71..4d34f55633 100644 --- 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 @@ -20,7 +20,7 @@ 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.PipelineDefinitionRQ; +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; @@ -217,17 +217,12 @@ void getPipelines_WithSearch_ShouldMatchPipelineNameOrAnyOfItsIterations() throw @Test void createIteration_ForNewPipelineName_ShouldCreatePipelineWithTypeAndLinkTestCases() throws Exception { - var rq = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.builder().name("brand-new-pipeline").type("TEST_AUTOMATION") - .attributes(List.of(main("repo", "org/auto"))).build()) - .attributes(List.of(main("trigger", "CI - smoke pack"))) - .stages(List.of(PipelineStageRQ.builder() + 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())).build()).build(); var detail = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -250,7 +245,7 @@ void createIteration_ForNewPipelineName_ShouldCreatePipelineWithTypeAndLinkTestC @Test void createIteration_WithADifferentPipelineTypeThanRecorded_ShouldBeBadRequest() throws Exception { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("factory-tc-gen").type("TEST_AUTOMATION").build()).build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").type("TEST_AUTOMATION").iteration(IterationRQ.builder().build()).build(); mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -261,14 +256,8 @@ void createIteration_WithADifferentPipelineTypeThanRecorded_ShouldBeBadRequest() @Test void createIteration_WithIdempotencyKey_ShouldReplaySameBodyAndRejectDifferentBody() throws Exception { - var rq = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Passed").build())) - .build(); - var other = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(PipelineStageRQ.builder().stageKey("stage-gen-tc").status("Failed").build())) - .build(); + 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) @@ -299,7 +288,7 @@ void createIteration_WithIdempotencyKey_ShouldReplaySameBodyAndRejectDifferentBo @Test void createIteration_ForExistingPipelineNameWithRerunOf_ShouldIncrementNumberAndLinkTheSource() throws Exception { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).rerunOfIterationId(901L).build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().rerunOfIterationId(901L).build()).build(); mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -314,9 +303,7 @@ void createIteration_ForExistingPipelineNameWithRerunOf_ShouldIncrementNumberAnd @Test void createIteration_WithNestedStageSectionsAndResultPointer_ShouldRoundTripTreeTablesAndResult() throws Exception { - var rq = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.named("brand-new-automation-pipeline")) - .stages(List.of( + var rq = PipelineIterationRQ.builder().name("brand-new-automation-pipeline").iteration(IterationRQ.builder().stages(List.of( PipelineStageRQ.builder() .stageKey("automate") .status("Passed") @@ -334,8 +321,7 @@ void createIteration_WithNestedStageSectionsAndResultPointer_ShouldRoundTripTree .stageKey("run-suite") .parentStageKey("automate") .status("Passed") - .build())) - .build(); + .build())).build()).build(); var detail = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -359,10 +345,10 @@ void createIteration_WithNestedStageSectionsAndResultPointer_ShouldRoundTripTree @Test void createIteration_WithBareScalarCells_ShouldAcceptThem() throws Exception { var json = """ - {"pipeline": {"name": "scalar-cells"}, - "stages": [{"stageKey": "grade", "status": "Passed", + {"name": "scalar-cells", + "iteration": {"stages": [{"stageKey": "grade", "status": "Passed", "sections": [{"key": "grades", "columns": [{"key": "score", "valueType": "SCORE"}], - "rows": [{"key": "r1", "cells": {"score": 90}}]}]}]} + "rows": [{"key": "r1", "cells": {"score": 90}}]}]}]}} """; var detail = body(mockMvc.perform(post(BASE + "/iteration") @@ -379,7 +365,7 @@ void createIteration_WithBareScalarCells_ShouldAcceptThem() throws Exception { void reportStage_ShouldReportAStageStepByStepAndKeepTheIterationStatusInStep() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("incremental")).build())) + .content(mapper.writeValueAsString(PipelineIterationRQ.builder().name("incremental").iteration(IterationRQ.builder().build()).build())) .with(token(oAuthHelper.getSuperadminToken()))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.statusCategory").value("PENDING"))); @@ -407,7 +393,7 @@ void reportStage_ShouldReportAStageStepByStepAndKeepTheIterationStatusInStep() t void reportStage_OfAFinishedStageByAnotherJob_ShouldStartANewAttemptAndKeepTheFinishedOne() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("attempts")).build())) + .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"; @@ -434,7 +420,7 @@ private JsonNode report(String url, String status, String jobId) throws Exceptio void reportStage_ForAStageWithAnUnknownStatusText_ShouldKeepTheTextAndShowItNeutrally() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("unknown-status")).build())) + .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") @@ -452,15 +438,12 @@ void generationPipeline_ShouldGetAReviewStageMaintainedByReportPortalAndFailedGr var draft = tmsTestCaseRepository.findById(901L).orElseThrow(); draft.setStatus(TmsTestCaseStatus.DRAFT); tmsTestCaseRepository.saveAndFlush(draft); - var rq = PipelineIterationRQ.builder() - .pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of( + 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(); + new PipelineSection.EntityReference("TEST_CASE", "TC-901")))).build())).build()).build(); var detail = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) @@ -481,7 +464,7 @@ void generationPipeline_ShouldGetAReviewStageMaintainedByReportPortalAndFailedGr void reportStage_ForTheReviewStageMaintainedByReportPortal_ShouldBeRejected() throws Exception { var created = body(mockMvc.perform(post(BASE + "/iteration") .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build())) + .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") 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 index 9237c4f88f..b80d9fe1b5 100644 --- 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 @@ -4,7 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.epam.reportportal.base.core.aifactory.dto.PipelineDefinitionRQ; +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; @@ -15,9 +15,7 @@ class IngestFingerprintTest { private PipelineIterationRQ rq(Map document, String status) { - return PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("p")) - .stages(List.of(PipelineStageRQ.builder().stageKey("s").sequence(1).status(status).document(document).build())) - .build(); + return PipelineIterationRQ.builder().name("p").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("s").sequence(1).status(status).document(document).build())).build()).build(); } @Test 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 index c7fbda78ed..714a36f371 100644 --- 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 @@ -13,7 +13,7 @@ import static org.mockito.Mockito.when; import com.epam.reportportal.base.core.aifactory.PipelineTestData; -import com.epam.reportportal.base.core.aifactory.dto.PipelineDefinitionRQ; +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; @@ -211,8 +211,7 @@ void create_NewPipelineFirstIteration_ShouldCreatePipelineWithTypeAndNumberOne() when(pipelineRepository.findByIdForUpdate(3L)).thenAnswer(inv -> Optional.of(created)); when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(3L)) .thenReturn(Optional.empty()); - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("new-pipeline").type("TEST_CASE_GENERATION").build()) - .stages(List.of(stageRQ("create", "Passed", null))).build(); + 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); @@ -231,7 +230,7 @@ void create_ExistingPipelineWithPriorIterations_ShouldIncrementIterationNumber() previous.setIterationNumber(3); when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipeline.getId())) .thenReturn(Optional.of(previous)); - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -243,9 +242,7 @@ void create_ExistingPipelineWithPriorIterations_ShouldIncrementIterationNumber() @Test void create_ShouldRecordPipelineTypeTheFirstTimeAndMergePipelineAttributes() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("factory-tc-gen") - .type("TEST_CASE_GENERATION") - .attributes(List.of(PipelineAttribute.builder().key("repo").value("org/repo").build())).build()).build(); + 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); @@ -256,7 +253,7 @@ void create_ShouldRecordPipelineTypeTheFirstTimeAndMergePipelineAttributes() { @Test void create_WithADifferentTypeThanRecorded_ShouldBeRejected() { pipeline.setType("TEST_AUTOMATION"); - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.builder().name("factory-tc-gen").type("TEST_CASE_GENERATION").build()).build(); + 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)); @@ -266,7 +263,7 @@ void create_WithADifferentTypeThanRecorded_ShouldBeRejected() { @Test void create_WithoutStages_ShouldBeAllowedAndRefreshTheDerivedStatus() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); var result = sut.createPipelineIteration(projectId, userId, rq, null); @@ -277,11 +274,9 @@ void create_WithoutStages_ShouldBeAllowedAndRefreshTheDerivedStatus() { @Test void create_WithAttributes_ShouldStoreNormalizedAttributesSearchTextAndIndex() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .attributes(List.of( + 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(); + PipelineAttribute.builder().key("env").value("beta5").build())).build()).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -295,7 +290,7 @@ void create_WithAttributes_ShouldStoreNormalizedAttributesSearchTextAndIndex() { @Test void create_WithoutAttributes_ShouldLeaveThemNull() { - sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build()); + 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()); @@ -305,8 +300,7 @@ void create_WithoutAttributes_ShouldLeaveThemNull() { @Test void create_WithStages_ShouldSaveThemWithAttemptOneAndSequenceFromPositionWhenAbsent() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("create", "Passed", null), stageRQ("grade", "Running", null))).build(); + 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); @@ -322,11 +316,9 @@ void create_WithStages_ShouldSaveThemWithAttemptOneAndSequenceFromPositionWhenAb @Test void create_WithNestedStage_ShouldAssignParentStageIdAndPath() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of( + 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(); + PipelineStageRQ.builder().stageKey("child").status("Passed").parentStageKey("parent").build())).build()).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -344,31 +336,25 @@ void create_WithNestedStage_ShouldAssignParentStageIdAndPath() { @Test void create_WithUnknownParentStageKey_ShouldThrowBadRequest() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(PipelineStageRQ.builder().stageKey("child").status("Passed") - .parentStageKey("missing-parent").build())) - .build(); + 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().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of( + 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(); + 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().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("a", "Passed", null), stageRQ("a", "Passed", null))).build(); - var blank = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ(" ", "Passed", null))).build(); + 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)); @@ -377,9 +363,7 @@ void create_WithDuplicateOrBlankStageKeys_ShouldThrowBadRequest() { @Test void create_WithOversizedReport_ShouldBeRejected() { var document = Map.of("blob", "x".repeat((int) PipelineIterationServiceImpl.MAX_REPORT_BYTES)); - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(PipelineStageRQ.builder().stageKey("grade").status("Passed").document(document).build())) - .build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("grade").status("Passed").document(document).build())).build()).build(); assertThrows(ReportPortalException.class, () -> sut.createPipelineIteration(projectId, userId, rq)); verify(pipelineRepository, never()).findByProjectIdAndName(any(), any()); @@ -388,7 +372,7 @@ void create_WithOversizedReport_ShouldBeRejected() { @Test void create_RerunOfAnIterationOfTheSamePipeline_ShouldCreateNewIterationLinkedToTheSource() { var source = persistedIteration(99L); - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).rerunOfIterationId(99L).build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().rerunOfIterationId(99L).build()).build(); sut.createPipelineIteration(projectId, userId, rq); @@ -407,7 +391,7 @@ void create_RerunOfAnIterationOfAnotherPipeline_ShouldThrowNotFound() { source.setId(99L); source.setPipeline(other); when(pipelineIterationRepository.findById(99L)).thenReturn(Optional.of(source)); - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).rerunOfIterationId(99L).build(); + 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)); @@ -417,7 +401,7 @@ void create_RerunOfAnIterationOfAnotherPipeline_ShouldThrowNotFound() { @Test void create_ShouldPublishIngestedEventAfterRefreshing() { - sut.createPipelineIteration(projectId, userId, PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build()); + 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)); } @@ -426,7 +410,7 @@ void create_ShouldPublishIngestedEventAfterRefreshing() { @Test void create_WithBlankOrTooLongIdempotencyKey_ShouldThrowBadRequest() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); + 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, @@ -435,7 +419,7 @@ void create_WithBlankOrTooLongIdempotencyKey_ShouldThrowBadRequest() { @Test void create_ReplayOfTheSameKeyAndBody_ShouldAnswerWithTheFirstIterationAndCreateNothing() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); + 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)); @@ -452,7 +436,7 @@ void create_ReplayOfTheSameKeyAndBody_ShouldAnswerWithTheFirstIterationAndCreate @Test void create_SameKeyWithDifferentBody_ShouldThrowIdempotencyConflict() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); + 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")) @@ -466,7 +450,7 @@ void create_SameKeyWithDifferentBody_ShouldThrowIdempotencyConflict() { @Test void create_WithNewKey_ShouldStoreKeyAndFingerprintOnTheIteration() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")).build(); + var rq = PipelineIterationRQ.builder().name("factory-tc-gen").iteration(IterationRQ.builder().build()).build(); when(pipelineIterationRepository.findByPipelineIdAndIdempotencyKey(pipeline.getId(), "key-1")) .thenReturn(Optional.empty()); @@ -489,8 +473,7 @@ private static List relatedTestCases(String... 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().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("upload", "Passed", relatedTestCases("TC-1", "TC-404")))).build(); + 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); @@ -505,8 +488,7 @@ void create_WithStageRefs_ShouldHandThemToTheResolverAndWarnAboutTheUnresolvedOn @Test void create_ShouldTellTheHandlersWhetherEachStagePassed() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("create", "Passed", null), stageRQ("upload", "Running", null))).build(); + 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); @@ -521,8 +503,7 @@ void create_ShouldTellTheHandlersWhetherEachStagePassed() { 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().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("upload", "Passed", null))).build(); + 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); @@ -534,9 +515,7 @@ void create_WithAHandlerAnswer_ShouldAddItsAttributesToTheStage() { @Test void create_WithRelatedEntitiesOfATypeNothingHandles_ShouldKeepThemWithoutWarning() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("upload", "Passed", List.of(new PipelineSection.EntityReference("LAUNCH", "12"))))) - .build(); + 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); @@ -552,8 +531,7 @@ void create_WithRelatedEntitiesOfATypeNothingHandles_ShouldKeepThemWithoutWarnin @Test void create_WithoutRefs_ShouldNotCallTheResolvers() { - var rq = PipelineIterationRQ.builder().pipeline(PipelineDefinitionRQ.named("factory-tc-gen")) - .stages(List.of(stageRQ("upload", "Passed", null))).build(); + 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); From 9b2435c0e4bf90d928c39f2d608a668a9f1be19c Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 18:37:01 +0300 Subject: [PATCH 04/12] Pipelines implementation --- .../PipelineIterationE2eIntegrationTest.java | 741 ++++++++++++++++++ .../db/aifactory/e2e/pipeline-e2e-cleanup.sql | 7 + .../db/aifactory/e2e/pipeline-e2e-fill.sql | 19 + 3 files changed, 767 insertions(+) create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIterationE2eIntegrationTest.java create mode 100644 src/test/resources/db/aifactory/e2e/pipeline-e2e-cleanup.sql create mode 100644 src/test/resources/db/aifactory/e2e/pipeline-e2e-fill.sql 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..31765b5e30 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/controller/integration/PipelineIterationE2eIntegrationTest.java @@ -0,0 +1,741 @@ +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_gradeFinishesWithTheDocumentAndTheTable(); + 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"); + var latest = pipeline.get("latestIteration"); + assertIterationStatus(latest, "Running", "RUNNING"); + assertEquals(4, latest.get("stages").size()); + assertFalse(stage(latest, "Create").has("links"), "a summary stage carries no links"); + } + + 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, "Pending", "PENDING"); + 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_gradeFinishesWithTheDocumentAndTheTable() 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("documentSchemaName").asText()); + assertEquals(79, grade.get("document").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, "Pending", "PENDING"); + } + + 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, "Pending", "PENDING"); + } + + // ------------------------------------------------------------------ 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()); + var latest = pipeline.get("latestIteration"); + assertIterationStatus(latest, "In review", "WAITING"); + assertEquals(580000, latest.get("durationMillis").asLong()); + assertEquals("2", attribute(latest, "readyNow")); + + var cards = send(get(PIPELINES + "/" + pipelineId + "/iteration"), null, HttpStatus.OK); + assertEquals(iterationId, cards.get("content").get(0).get("id").asLong()); + + 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)"); + 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("documentSchemaName", "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"); + 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 document = body.putObject("document"); + document.put("total_score", 79).putArray("warnings"); + document.putNull("error"); + var documentCases = document.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 documentCase = documentCases.addObject().put("name", authored.name()).put("total_score", authored.total()); + var criteria = documentCase.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"); + 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/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)); From ad3bf28db2179fe0ed4f27a3e170c5bc097f6dd2 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 18:54:22 +0300 Subject: [PATCH 05/12] Pipelines implementation --- .../base/core/aifactory/dto/PipelineRS.java | 1 - .../core/aifactory/mapper/PipelineMapper.java | 3 +- .../service/PipelineServiceImpl.java | 19 ++------- .../PipelineIterationRepository.java | 9 ---- .../integration/PipelineIntegrationTest.java | 6 +-- .../PipelineIterationE2eIntegrationTest.java | 14 +++---- .../aifactory/mapper/PipelineMapperTest.java | 5 +-- .../service/PipelineServiceImplTest.java | 41 ++----------------- 8 files changed, 17 insertions(+), 81 deletions(-) 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 index d2ee3158af..a182e1ec68 100644 --- 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 @@ -29,6 +29,5 @@ public class PipelineRS { private Map settings; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private long iterationsCount; - private PipelineIterationSummaryRS latestIteration; private Instant createdAt; } 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 index 9057fcea89..ee5e710163 100644 --- 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 @@ -41,7 +41,7 @@ @Component public class PipelineMapper { - public PipelineRS toPipelineRS(Pipeline pipeline, long iterationsCount, PipelineIterationSummaryRS latestIteration) { + public PipelineRS toPipelineRS(Pipeline pipeline, long iterationsCount) { return PipelineRS.builder() .id(pipeline.getId()) .name(pipeline.getName()) @@ -50,7 +50,6 @@ public PipelineRS toPipelineRS(Pipeline pipeline, long iterationsCount, Pipeline .attributes(attributeList(pipeline.getAttributes())) .settings(pipeline.getSettings() == null ? null : pipeline.getSettings().getValues()) .iterationsCount(iterationsCount) - .latestIteration(latestIteration) .createdAt(pipeline.getCreatedAt()) .build(); } 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 index 47c6960718..e03566e000 100644 --- 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 @@ -1,6 +1,5 @@ package com.epam.reportportal.base.core.aifactory.service; -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.mapper.PipelineMapper; @@ -16,7 +15,6 @@ import java.util.List; import java.util.LinkedHashMap; import java.util.Map; -import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -31,7 +29,6 @@ public class PipelineServiceImpl implements PipelineService { private final PipelineRepository pipelineRepository; private final PipelineIterationRepository pipelineIterationRepository; - private final PipelineViewAssembler viewAssembler; private final PipelineMapper pipelineMapper; private final List settingsValidators; @@ -46,7 +43,7 @@ public Page getPipelines(Long projectId, String search, OffsetReques .map(Pipeline::getId) .toList(); if (pipelineIds.isEmpty()) { - return page.map(pipeline -> pipelineMapper.toPipelineRS(pipeline, 0, null)); + return page.map(pipeline -> pipelineMapper.toPipelineRS(pipeline, 0)); } var iterationsCountByPipelineId = pipelineIterationRepository @@ -56,14 +53,8 @@ public Page getPipelines(Long projectId, String search, OffsetReques PipelineIterationRepository.PipelineIterationCount::getPipelineId, PipelineIterationRepository.PipelineIterationCount::getCount) ); - var latestIterations = pipelineIterationRepository.findLatestByPipelineIdIn(pipelineIds); - var latestSummaryByPipelineId = viewAssembler.summaries(latestIterations) - .stream() - .collect(Collectors.toMap(PipelineIterationSummaryRS::getPipelineId, Function.identity())); - return page.map(pipeline -> pipelineMapper.toPipelineRS(pipeline, - iterationsCountByPipelineId.getOrDefault(pipeline.getId(), 0L), - latestSummaryByPipelineId.get(pipeline.getId()))); + iterationsCountByPipelineId.getOrDefault(pipeline.getId(), 0L))); } @Override @@ -92,10 +83,6 @@ public PipelineRS patchPipelineSettings(Long projectId, Long pipelineId, Pipelin private PipelineRS toPipelineRS(Pipeline pipeline) { var iterationsCount = pipelineIterationRepository.countByPipelineId(pipeline.getId()); - var latest = pipelineIterationRepository - .findFirstByPipelineIdOrderByIterationNumberDesc(pipeline.getId()) - .map(viewAssembler::summary) - .orElse(null); - return pipelineMapper.toPipelineRS(pipeline, iterationsCount, latest); + return pipelineMapper.toPipelineRS(pipeline, iterationsCount); } } 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 index 634980a794..aa064d5acb 100644 --- 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 @@ -36,15 +36,6 @@ public interface PipelineIterationRepository extends JpaRepository countByPipelineIdIn(@Param("pipelineIds") Collection pipelineIds); - /** - * Batched form of {@link #findFirstByPipelineIdOrderByIterationNumberDesc(Long)} — the latest - * iteration per pipeline in one query instead of one per pipeline. - */ - @Query("SELECT it FROM PipelineIteration it WHERE it.pipeline.id IN :pipelineIds " - + "AND it.iterationNumber = (SELECT MAX(it2.iterationNumber) FROM PipelineIteration it2 " - + "WHERE it2.pipeline.id = it.pipeline.id)") - List findLatestByPipelineIdIn(@Param("pipelineIds") Collection pipelineIds); - interface PipelineIterationCount { Long getPipelineId(); 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 index 4d34f55633..1a57db154c 100644 --- 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 @@ -108,15 +108,13 @@ private static PipelineAttribute main(String key, String value) { // ------------------------------------------------------------------ pipelines @Test - void getPipelines_ShouldReturnOnlyThisProjectsPipelinesWithTypeAttributesAndLatestIterationSummary() throws Exception { + 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.iterationNumber").value(2)) - .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].latestIteration.status").value("In review")) - .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].latestIteration.statusCategory").value("WAITING")) + .andExpect(jsonPath("$.content[?(@.name=='factory-tc-gen')].latestIteration").isEmpty()) .andExpect(jsonPath("$.content[?(@.name=='other-project-pipeline')]").isEmpty()); } 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 index 31765b5e30..95a850559c 100644 --- 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 @@ -217,10 +217,7 @@ private void step2_pipelinesPageShowsTheRunningIteration() throws Exception { 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"); - var latest = pipeline.get("latestIteration"); - assertIterationStatus(latest, "Running", "RUNNING"); - assertEquals(4, latest.get("stages").size()); - assertFalse(stage(latest, "Create").has("links"), "a summary stage carries no links"); + assertFalse(pipeline.has("latestIteration"), "iterations are read by expanding the group (step 3)"); } private void step3_iterationCardsOfThePipeline() throws Exception { @@ -439,14 +436,15 @@ 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()); - var latest = pipeline.get("latestIteration"); + 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 cards = send(get(PIPELINES + "/" + pipelineId + "/iteration"), null, HttpStatus.OK); - assertEquals(iterationId, cards.get("content").get(0).get("id").asLong()); - var detail = iteration(); assertIterationStatus(detail, "In review", "WAITING"); var linkedCreateRow = cellNode(stage(detail, "Create"), 0, 2, "libraryId"); 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 index 1f48aa3f06..5382ed1ff0 100644 --- 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 @@ -7,7 +7,6 @@ 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.PipelineIterationSummaryRS; 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; @@ -72,11 +71,10 @@ void setUp() { @Test void toPipelineRS_ShouldMapAllFields() { - var summary = PipelineIterationSummaryRS.builder().id(7L).build(); pipeline.setAttributes(new PipelineAttributes(List.of( PipelineAttribute.builder().key("repo").value("org/repo").build()))); - var result = mapper.toPipelineRS(pipeline, 5L, summary); + var result = mapper.toPipelineRS(pipeline, 5L); assertEquals(pipeline.getId(), result.getId()); assertEquals(pipeline.getName(), result.getName()); @@ -85,7 +83,6 @@ void toPipelineRS_ShouldMapAllFields() { assertEquals("repo", result.getAttributes().get(0).getKey()); assertEquals(Map.of("autoReady", Map.of("enabled", true, "threshold", 90)), result.getSettings()); assertEquals(5L, result.getIterationsCount()); - assertEquals(summary, result.getLatestIteration()); assertEquals(pipeline.getCreatedAt(), result.getCreatedAt()); } 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 index 62d564abe7..2ed3882775 100644 --- 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 @@ -9,14 +9,12 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -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.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.PipelineIteration; 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; @@ -46,8 +44,6 @@ class PipelineServiceImplTest { @Mock private PipelineIterationRepository pipelineIterationRepository; @Mock - private PipelineViewAssembler viewAssembler; - @Mock private PipelineMapper pipelineMapper; @Mock private PipelineSettingsValidator settingsValidator; @@ -80,9 +76,7 @@ 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(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipelineId)) - .thenReturn(Optional.empty()); - when(pipelineMapper.toPipelineRS(pipeline, 0L, null)).thenReturn(expected); + when(pipelineMapper.toPipelineRS(pipeline, 0L)).thenReturn(expected); } @Test @@ -160,46 +154,21 @@ void patchSettings_WhenPipelineNotFound_ShouldThrowNotFound() { } @Test - void patchSettings_ShouldReturnTheLatestIterationSummary() { - var latest = new PipelineIteration(); - var summary = PipelineIterationSummaryRS.builder().id(42L).build(); - var expectedRS = PipelineRS.builder().id(pipelineId).build(); - when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); - when(pipelineRepository.save(pipeline)).thenReturn(pipeline); - when(pipelineIterationRepository.countByPipelineId(pipelineId)).thenReturn(3L); - when(pipelineIterationRepository.findFirstByPipelineIdOrderByIterationNumberDesc(pipelineId)) - .thenReturn(Optional.of(latest)); - when(viewAssembler.summary(latest)).thenReturn(summary); - when(pipelineMapper.toPipelineRS(pipeline, 3L, summary)).thenReturn(expectedRS); - - assertEquals(expectedRS, - sut.patchPipelineSettings(projectId, pipelineId, PipelineSettingsRQ.builder().settings(Map.of()).build())); - } - - @Test - void listPipelines_ShouldBatchIterationCountsAndLatestIterationSummariesAcrossThePage() { + void listPipelines_ShouldBatchIterationCountsAcrossThePage() { var offsetRequest = new OffsetRequest(0, 50); var page = new PageImpl<>(List.of(pipeline)); - var latestIteration = new PipelineIteration(); - latestIteration.setId(42L); - latestIteration.setPipeline(pipeline); - var summary = PipelineIterationSummaryRS.builder().id(42L).pipelineId(pipelineId).build(); 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(pipelineIterationRepository.findLatestByPipelineIdIn(List.of(pipelineId))) - .thenReturn(List.of(latestIteration)); - when(viewAssembler.summaries(List.of(latestIteration))).thenReturn(List.of(summary)); - when(pipelineMapper.toPipelineRS(pipeline, 3L, summary)).thenReturn(expectedRS); + 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()); - verify(pipelineIterationRepository, never()).findFirstByPipelineIdOrderByIterationNumberDesc(any()); } @Test @@ -209,9 +178,7 @@ void listPipelines_WithoutIterations_ShouldStillReturnThePipelineWithZeroCount() when(pipelineRepository.findAll(org.mockito.ArgumentMatchers.>any(), any(Pageable.class))) .thenReturn(page); when(pipelineIterationRepository.countByPipelineIdIn(List.of(pipelineId))).thenReturn(List.of()); - when(pipelineIterationRepository.findLatestByPipelineIdIn(List.of(pipelineId))).thenReturn(List.of()); - when(viewAssembler.summaries(List.of())).thenReturn(List.of()); - when(pipelineMapper.toPipelineRS(pipeline, 0L, null)).thenReturn(expectedRS); + when(pipelineMapper.toPipelineRS(pipeline, 0L)).thenReturn(expectedRS); var result = sut.getPipelines(projectId, null, new OffsetRequest(0, 50)); From b645a0dd3614d96085b18aabda9adff54dceec1a Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 19:13:37 +0300 Subject: [PATCH 06/12] Pipelines implementation --- .../service/PipelineIterationRefresher.java | 12 ++++++++++- .../PipelineIterationE2eIntegrationTest.java | 6 +++--- .../PipelineIterationRefresherTest.java | 20 +++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) 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 index 4d41d76b3f..4225a32372 100644 --- 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 @@ -69,7 +69,7 @@ public PipelineIteration refresh(PipelineIteration iteration) { && stage.getStatusCategory() == StatusCategory.RUNNING)) { categories.add(StatusCategory.RUNNING); } - var category = categories.isEmpty() ? derivedWhenNothingCounts(stages) : StatusCategory.aggregate(categories); + var category = categories.isEmpty() ? derivedWhenNothingCounts(stages) : iterationCategory(categories); iteration.setStatusCategory(category); iteration.setStatus(applicable.stream() .map(contributor -> contributor.iterationStatusLabel(category)) @@ -96,6 +96,16 @@ public void refreshIterations(Collection iterationIds) { } } + /** + * 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. 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 index 95a850559c..5487253388 100644 --- 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 @@ -259,7 +259,7 @@ private void step5_editorSwitchesAutoReadyOn() throws Exception { private void step6_createFinishes() throws Exception { var detail = reportStage("create", createStageBody(false)); - assertIterationStatus(detail, "Pending", "PENDING"); + assertIterationStatus(detail, "Running", "RUNNING"); var create = stage(detail, "Create"); assertStage(create, "Passed", "PASSED", "PIPELINE", true); assertEquals(362000, create.get("durationMillis").asLong()); @@ -306,7 +306,7 @@ private void step8_gradeFinishesWithTheDocumentAndTheTable() throws Exception { 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, "Pending", "PENDING"); + assertIterationStatus(detail, "Running", "RUNNING"); } private void step9_suiteScoreGoesToTheIteration() throws Exception { @@ -319,7 +319,7 @@ private void step9_suiteScoreGoesToTheIteration() throws Exception { 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, "Pending", "PENDING"); + assertIterationStatus(detail, "Running", "RUNNING"); } // ------------------------------------------------------------------ step 10 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 index 3e8df54d1a..8261bdd160 100644 --- 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 @@ -72,6 +72,26 @@ void refresh_WhenAStageThatDoesNotCountIsRunning_ShouldStillShowTheIterationAsRu 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), From e5e5ddc7697e1dddd05630dd45c3e8ac030a17e5 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 19:22:10 +0300 Subject: [PATCH 07/12] Pipelines implementation --- .../core/aifactory/dto/PipelineStageRQ.java | 12 +++++----- .../core/aifactory/dto/PipelineStageRS.java | 6 ++--- .../core/aifactory/mapper/PipelineMapper.java | 10 ++++---- .../service/PipelineIterationServiceImpl.java | 18 +++++++------- ...r.java => PipelineRawOutputValidator.java} | 22 ++++++++--------- ...neDocument.java => PipelineRawOutput.java} | 10 ++++---- .../entity/aifactory/PipelineStage.java | 12 +++++----- .../PipelineIterationE2eIntegrationTest.java | 22 ++++++++--------- .../aifactory/mapper/PipelineMapperTest.java | 24 +++++++++---------- .../service/IngestFingerprintTest.java | 4 ++-- .../PipelineIterationServiceImplTest.java | 18 +++++++------- ...va => PipelineRawOutputValidatorTest.java} | 20 ++++++++-------- 12 files changed, 89 insertions(+), 89 deletions(-) rename src/main/java/com/epam/reportportal/base/core/aifactory/service/{PipelineDocumentValidator.java => PipelineRawOutputValidator.java} (60%) rename src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/{PipelineDocument.java => PipelineRawOutput.java} (71%) rename src/test/java/com/epam/reportportal/base/core/aifactory/service/{PipelineDocumentValidatorTest.java => PipelineRawOutputValidatorTest.java} (68%) 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 index 0390c541df..406e3c3539 100644 --- 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 @@ -79,16 +79,16 @@ public class PipelineStageRQ { @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 = "A document produced by the stage, kept exactly as received. Example: the grader's " + @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 document; + private Map rawOutput; @Size(max = 128) - @Schema(description = "Name of the schema the document must follow. When ReportPortal has a schema with " - + "this name the document is checked against it; a document that does not match is rejected and the " + @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 document that needs no check") - private String documentSchemaName; + + "Leave it out for a raw output that needs no check") + private String rawOutputSchemaName; private PipelineStepResultRQ result; 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 index 73bfab13b0..67756d7aeb 100644 --- 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 @@ -55,9 +55,9 @@ public class PipelineStageRS { @Schema(description = "Result tables of the stage") private List sections; private PipelineStepResultRS result; - @Schema(description = "A document the stage reported, exactly as received") - private Map document; - private String documentSchemaName; + @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") 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 index ee5e710163..45bcfaef80 100644 --- 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 @@ -18,7 +18,7 @@ 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.PipelineDocument; +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; @@ -145,8 +145,8 @@ public PipelineStageRS toStageRS(PipelineStage stage) { .relatedEntities(relatedEntityList(stage.getRelatedEntities())) .sections(sectionList(stage.getSections())) .result(toStepResultRS(stage.getResultType(), stage.getResultRef())) - .document(stage.getDocument() == null ? null : stage.getDocument().getContent()) - .documentSchemaName(stage.getDocumentSchemaName()) + .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()) @@ -209,8 +209,8 @@ public void applyStage(PipelineStage stage, PipelineStageRQ rq) { 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.setDocument(rq.getDocument() == null ? null : new PipelineDocument(rq.getDocument())); - stage.setDocumentSchemaName(rq.getDocumentSchemaName()); + 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) { 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 index 8dc96c388a..2b7f2cf565 100644 --- 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 @@ -65,7 +65,7 @@ public class PipelineIterationServiceImpl implements PipelineIterationService { private final PipelineIterationRefresher refresher; private final PipelineComparator comparator; private final ApplicationEventPublisher eventPublisher; - private final PipelineDocumentValidator documentValidator; + private final PipelineRawOutputValidator rawOutputValidator; @Override @Transactional @@ -130,7 +130,7 @@ public PipelineIterationIngestResult createPipelineIteration(Long projectId, Lon for (int i = 0; i < stageRQs.size(); i++) { var stageRQ = stageRQs.get(i); var stage = pipelineMapper.toStageEntity(stageRQ); - checkDocument(stage); + checkRawOutput(stage); stage.setIteration(savedIteration); stage.setAttempt(1); if (stage.getSequence() == null) { @@ -177,7 +177,7 @@ public PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, S if (existing == null) { rq.setStageKey(stageKey); stage = pipelineMapper.toStageEntity(rq); - checkDocument(stage); + checkRawOutput(stage); stage.setIteration(iteration); stage.setAttempt(1); if (stage.getSequence() == null) { @@ -207,7 +207,7 @@ public PipelineIterationDetailRS reportStage(Long projectId, Long iterationId, S previousCategory = StatusCategory.PENDING; } pipelineMapper.applyStage(stage, rq); - checkDocument(stage); + checkRawOutput(stage); stage = pipelineStageRepository.save(stage); becamePassed = previousCategory != StatusCategory.PASSED && stage.getStatusCategory() == StatusCategory.PASSED; } @@ -301,13 +301,13 @@ private void validateStages(List stageRQs) { } /** - * A reported document that does not match the schema it names is rejected: it is not kept, and the + * 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 checkDocument(PipelineStage stage) { - var document = stage.getDocument() == null ? null : stage.getDocument().getContent(); - documentValidator.validate(stage.getDocumentSchemaName(), document).ifPresent(reason -> { - stage.setDocument(null); + 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( diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidator.java b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidator.java similarity index 60% rename from src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidator.java rename to src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidator.java index 440aef7438..8d486e5fd3 100644 --- a/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidator.java +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidator.java @@ -13,13 +13,13 @@ import org.springframework.stereotype.Component; /** - * Checks a document 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 documents of their own. + * 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 PipelineDocumentValidator { +public class PipelineRawOutputValidator { private static final String SCHEMA_BASE = "https://schema.reportportal.io/pipeline/"; private static final String RESOURCE_BASE = "schema/pipeline/"; @@ -31,23 +31,23 @@ public class PipelineDocumentValidator { private final ObjectMapper objectMapper; /** - * @return why the document is not valid, or empty when it is valid or there is no schema to check it with + * @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 documentSchemaName, Map document) { - if (documentSchemaName == null || document == null || !SAFE_REF.matcher(documentSchemaName).matches() - || !new ClassPathResource(RESOURCE_BASE + documentSchemaName + SUFFIX).exists()) { + 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 + documentSchemaName + SUFFIX, objectMapper.valueToTree(document)); + var messages = schemaValidator.validate(SCHEMA_BASE + rawOutputSchemaName + SUFFIX, objectMapper.valueToTree(rawOutput)); if (messages.isEmpty()) { return Optional.empty(); } - var reason = "Document does not match schema '" + documentSchemaName + "': " + 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 '" + documentSchemaName + "'", e); + throw new IllegalStateException("Could not read schema '" + rawOutputSchemaName + "'", e); } } } diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineDocument.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRawOutput.java similarity index 71% rename from src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineDocument.java rename to src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRawOutput.java index 854bec062c..0403a17255 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineDocument.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/aifactory/PipelineRawOutput.java @@ -10,17 +10,17 @@ import lombok.NoArgsConstructor; /** - * A document a stage reported, kept exactly as received (e.g. the grader's output) next to the + * 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 PipelineDocument extends JsonbUserType implements Serializable { +public class PipelineRawOutput extends JsonbUserType implements Serializable { private Map content = new LinkedHashMap<>(); @JsonCreator(mode = JsonCreator.Mode.DELEGATING) - public PipelineDocument(Map content) { + public PipelineRawOutput(Map content) { this.content = content == null ? new LinkedHashMap<>() : new LinkedHashMap<>(content); } @@ -30,7 +30,7 @@ public Map getContent() { } @Override - public Class returnedClass() { - return PipelineDocument.class; + public Class returnedClass() { + return PipelineRawOutput.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 index f9a19ea6e7..93930fe254 100644 --- 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 @@ -38,7 +38,7 @@ *

* {@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 document} keeps a reported document as received. + * {@code sections} (result tables); {@code rawOutput} keeps what the stage reported as received. */ @Entity @Table(name = "pipeline_stage", schema = "public") @@ -113,12 +113,12 @@ public class PipelineStage implements Serializable { @Column(name = "links", columnDefinition = "jsonb") private PipelineLinks links; - @Type(PipelineDocument.class) - @Column(name = "document", columnDefinition = "jsonb") - private PipelineDocument document; + @Type(PipelineRawOutput.class) + @Column(name = "raw_output", columnDefinition = "jsonb") + private PipelineRawOutput rawOutput; - @Column(name = "document_schema_name") - private String documentSchemaName; + @Column(name = "raw_output_schema_name") + private String rawOutputSchemaName; @Column(name = "result_type") private String resultType; 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 index 5487253388..2d440dd7fd 100644 --- 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 @@ -125,7 +125,7 @@ void fullScenario_FromTheFirstReportToAllTestCasesReady() throws Exception { step5_editorSwitchesAutoReadyOn(); step6_createFinishes(); step7_gradeStarts(); - step8_gradeFinishesWithTheDocumentAndTheTable(); + step8_gradeFinishesWithTheRawOutputAndTheTable(); step9_suiteScoreGoesToTheIteration(); step10_agentWritesTheTestCasesToTheLibrary(); step11_uploadStarts(); @@ -291,7 +291,7 @@ private void step7_gradeStarts() throws Exception { assertIterationStatus(detail, "Running", "RUNNING"); } - private void step8_gradeFinishesWithTheDocumentAndTheTable() throws Exception { + private void step8_gradeFinishesWithTheRawOutputAndTheTable() throws Exception { var detail = reportStage("grade", gradeStageBody(false)); var grade = stage(detail, "Grade"); @@ -299,8 +299,8 @@ private void step8_gradeFinishesWithTheDocumentAndTheTable() throws Exception { assertEquals(151000, grade.get("durationMillis").asLong()); assertEquals("79", grade.get("summary").asText()); assertEquals("79", attribute(grade, "suiteScore")); - assertEquals("grade", grade.get("documentSchemaName").asText()); - assertEquals(79, grade.get("document").get("total_score").asInt()); + 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"); @@ -530,7 +530,7 @@ 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("documentSchemaName", "grade"); + .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")); @@ -548,10 +548,10 @@ private String gradeStageBody(boolean withLibraryIds) { } column(columns, "total", "TOTAL /100", "SCORE"); var rows = graded.putArray("rows"); - var document = body.putObject("document"); - document.put("total_score", 79).putArray("warnings"); - document.putNull("error"); - var documentCases = document.putArray("cases"); + 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)); @@ -561,8 +561,8 @@ private String gradeStageBody(boolean withLibraryIds) { } cells.put("name", authored.name()); var details = row.putArray("details"); - var documentCase = documentCases.addObject().put("name", authored.name()).put("total_score", authored.total()); - var criteria = documentCase.putArray("criteria"); + 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]) 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 index 5382ed1ff0..c983d001d9 100644 --- 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 @@ -20,7 +20,7 @@ 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.PipelineDocument; +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; @@ -308,12 +308,12 @@ void toStageRS_WithoutCiOrResult_ShouldLeaveThemNull() { assertNull(result.getResult()); assertNull(result.getParentStageId()); assertNull(result.getSections()); - assertNull(result.getDocument()); + assertNull(result.getRawOutput()); assertFalse(result.isRetryable()); } @Test - void toStageRS_WithDetails_ShouldMapAttributesSectionsDocumentResultAndLinks() { + void toStageRS_WithDetails_ShouldMapAttributesSectionsRawOutputResultAndLinks() { var stage = new PipelineStage(); stage.setId(50L); stage.setStageKey("grade"); @@ -325,8 +325,8 @@ void toStageRS_WithDetails_ShouldMapAttributesSectionsDocumentResultAndLinks() { .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.setDocument(new PipelineDocument(Map.of("total_score", 90))); - stage.setDocumentSchemaName("grade.schema.json@1"); + stage.setRawOutput(new PipelineRawOutput(Map.of("total_score", 90))); + stage.setRawOutputSchemaName("grade.schema.json@1"); stage.setResultType("LAUNCH"); stage.setResultRef("12"); @@ -336,8 +336,8 @@ void toStageRS_WithDetails_ShouldMapAttributesSectionsDocumentResultAndLinks() { assertTrue(result.isRetryable()); assertEquals("agent", result.getAttributes().get(0).getKey()); assertEquals("grades", result.getSections().get(0).getKey()); - assertEquals(Map.of("total_score", 90), result.getDocument()); - assertEquals("grade.schema.json@1", result.getDocumentSchemaName()); + 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()); @@ -354,8 +354,8 @@ void toStageEntity_WithExecution_ShouldKeepItAsReportedAlongWithTheRest() { .affectsIterationStatus(false) .attributes(List.of(PipelineAttribute.builder().key("agent").value("create-test-cases@0.4").build())) .sections(List.of(PipelineSection.builder().key("rows").build())) - .document(Map.of("k", "v")) - .documentSchemaName("s@1") + .rawOutput(Map.of("k", "v")) + .rawOutputSchemaName("s@1") .result(PipelineStepResultRQ.builder().resultType("LAUNCH").resultRef("12").build()) .execution(PipelineExecution.builder() .executorType("GITHUB_ACTIONS") @@ -377,8 +377,8 @@ void toStageEntity_WithExecution_ShouldKeepItAsReportedAlongWithTheRest() { 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.getDocument().getContent()); - assertEquals("s@1", stage.getDocumentSchemaName()); + 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(); @@ -398,7 +398,7 @@ void toStageEntity_WithoutExecutionOrOptionalParts_ShouldLeaveThemUnsetAndCountT assertNull(stage.getExecution()); assertNull(stage.getAttributes()); assertNull(stage.getSections()); - assertNull(stage.getDocument()); + assertNull(stage.getRawOutput()); assertNull(stage.getResultType()); assertTrue(stage.isAffectsIterationStatus()); assertEquals("stage-reqs", stage.getName()); 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 index b80d9fe1b5..1edddbb168 100644 --- 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 @@ -14,8 +14,8 @@ class IngestFingerprintTest { - private PipelineIterationRQ rq(Map document, String status) { - return PipelineIterationRQ.builder().name("p").iteration(IterationRQ.builder().stages(List.of(PipelineStageRQ.builder().stageKey("s").sequence(1).status(status).document(document).build())).build()).build(); + 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 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 index 714a36f371..81698bf07a 100644 --- 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 @@ -95,7 +95,7 @@ class PipelineIterationServiceImplTest { @Mock private ApplicationEventPublisher eventPublisher; @Mock - private PipelineDocumentValidator documentValidator; + private PipelineRawOutputValidator rawOutputValidator; private PipelineIterationServiceImpl sut; @@ -115,7 +115,7 @@ void setUp() { sut = new PipelineIterationServiceImpl(pipelineRepository, pipelineIterationRepository, pipelineStageRepository, pipelineStageAttemptRepository, relatedEntityResolvers, stageReportedHandlers, projectRepository, pipelineMapper, viewAssembler, refresher, comparator, eventPublisher, - documentValidator); + rawOutputValidator); project = new Project(); project.setId(projectId); @@ -362,8 +362,8 @@ void create_WithDuplicateOrBlankStageKeys_ShouldThrowBadRequest() { @Test void create_WithOversizedReport_ShouldBeRejected() { - var document = 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").document(document).build())).build()).build(); + 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()); @@ -563,13 +563,13 @@ void reportStage_ForANewStage_ShouldCreateItAfterTheExistingOnesWithRootPathAndR } @Test - void reportStage_WithADocumentThatFailsItsSchema_ShouldFailTheStageAndNotKeepTheDocument() { + void reportStage_WithARawOutputThatFailsItsSchema_ShouldFailTheStageAndNotKeepIt() { persistedIteration(10L); when(pipelineStageRepository.findByIterationIdOrderBySequenceAsc(10L)).thenReturn(List.of()); - when(documentValidator.validate(org.mockito.ArgumentMatchers.eq("grade"), any())) + when(rawOutputValidator.validate(org.mockito.ArgumentMatchers.eq("grade"), any())) .thenReturn(Optional.of("total_score is missing")); - var rq = PipelineStageRQ.builder().status("Passed").documentSchemaName("grade") - .document(java.util.Map.of("warnings", List.of())).build(); + var rq = PipelineStageRQ.builder().status("Passed").rawOutputSchemaName("grade") + .rawOutput(java.util.Map.of("warnings", List.of())).build(); sut.reportStage(projectId, 10L, "grade", rq); @@ -578,7 +578,7 @@ void reportStage_WithADocumentThatFailsItsSchema_ShouldFailTheStageAndNotKeepThe var stage = captor.getValue(); assertEquals(StatusCategory.FAILED, stage.getStatusCategory()); assertEquals("Failed", stage.getStatus()); - org.junit.jupiter.api.Assertions.assertNull(stage.getDocument()); + 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()); diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidatorTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidatorTest.java similarity index 68% rename from src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidatorTest.java rename to src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidatorTest.java index e1c68028f0..676340fb34 100644 --- a/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineDocumentValidatorTest.java +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/service/PipelineRawOutputValidatorTest.java @@ -11,9 +11,9 @@ import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; -class PipelineDocumentValidatorTest { +class PipelineRawOutputValidatorTest { - private PipelineDocumentValidator validator; + private PipelineRawOutputValidator validator; @BeforeEach void setUp() { @@ -21,19 +21,19 @@ void setUp() { ReflectionTestUtils.setField(config, "schemaVersion", "V7"); ReflectionTestUtils.setField(config, "schemaSource", "https://schema.reportportal.io/"); ReflectionTestUtils.setField(config, "schemaLocation", "classpath:schema/"); - validator = new PipelineDocumentValidator(new JsonSchemaValidator(config), new ObjectMapper()); + validator = new PipelineRawOutputValidator(new JsonSchemaValidator(config), new ObjectMapper()); } @Test - void validate_WithAValidGradeDocument_ShouldHaveNoReason() { - var document = Map.of("total_score", 83, "warnings", List.of("w"), + 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", document).isEmpty()); + assertTrue(validator.validate("grade", rawOutput).isEmpty()); } @Test - void validate_WithAGradeDocumentWithoutSuiteScore_ShouldNameTheProblem() { + void validate_WithAGradeRawOutputWithoutSuiteScore_ShouldNameTheProblem() { var reason = validator.validate("grade", Map.of("warnings", List.of())); assertTrue(reason.isPresent()); @@ -42,14 +42,14 @@ void validate_WithAGradeDocumentWithoutSuiteScore_ShouldNameTheProblem() { @Test void validate_WithAWronglyTypedCaseScore_ShouldBeRejected() { - var document = Map.of("total_score", 83, + var rawOutput = Map.of("total_score", 83, "cases", List.of(Map.of("name", "Case", "total_score", "high"))); - assertTrue(validator.validate("grade", document).isPresent()); + assertTrue(validator.validate("grade", rawOutput).isPresent()); } @Test - void validate_WithoutASchemaOrDocumentOrWithAnUnsafeReference_ShouldNotCheck() { + 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()); From 41f094361b9212e1f243cdd80d3f8a09a65b410f Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 17 Oct 2026 19:28:28 +0300 Subject: [PATCH 08/12] Pipelines implementation --- .../integration/PipelineIterationE2eIntegrationTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 2d440dd7fd..841675135e 100644 --- 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 @@ -506,7 +506,8 @@ private String createStageBody(boolean withLibraryIds) { 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)"); + 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); @@ -539,7 +540,7 @@ private String gradeStageBody(boolean withLibraryIds) { 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"); + 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); @@ -593,7 +594,7 @@ private String uploadStageBody() { 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"); + 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); From 07a3a1d5c04edf8743cf0cba7856877fe56a0717 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Fri, 23 Oct 2026 09:03:24 +0200 Subject: [PATCH 09/12] Pipelines implementation --- .../aifactory/connector/CiRunRequest.java | 20 + .../connector/CiTriggerConnector.java | 14 + .../connector/GitHubActionsCiConnector.java | 35 ++ .../connector/GitLabCiConnector.java | 26 ++ .../controller/TestCaseReviewController.java | 143 +++++++ .../base/core/tms/dto/TmsFixPushRS.java | 48 +++ .../base/core/tms/dto/TmsReviewCommentRQ.java | 40 ++ .../base/core/tms/dto/TmsReviewCommentRS.java | 48 +++ .../tms/dto/TmsTestCaseFixedVersionRQ.java | 78 ++++ .../core/tms/dto/TmsTestCaseReviewRS.java | 31 ++ .../tms/mapper/TmsReviewCommentMapper.java | 31 ++ .../FixRoundIterationContributor.java | 57 +++ .../pipeline/FixRoundIterationSupport.java | 43 ++ .../tms/pipeline/FixRoundOnStageFailed.java | 34 ++ .../tms/pipeline/TmsFixPushTimeoutJob.java | 56 +++ .../pipeline/TmsTestCaseFixRoundService.java | 150 +++++++ .../TmsTestCasePushToAgentService.java | 247 +++++++++++ .../TmsTestCaseQualityServiceImpl.java | 2 + .../TmsTestCaseReadyEligibilityService.java | 60 +++ .../TmsTestCaseReviewCommentService.java | 37 ++ .../TmsTestCaseReviewCommentServiceImpl.java | 125 ++++++ .../tms/service/TmsTestCaseServiceImpl.java | 15 +- .../service/TmsTestCaseVersionService.java | 16 + .../TmsTestCaseVersionServiceImpl.java | 22 + .../exception/rest/StatusCodeMapping.java | 1 + .../dao/tms/TmsTestCaseFixPushRepository.java | 44 ++ .../TmsTestCaseReviewCommentRepository.java | 35 ++ .../persistence/entity/tms/TmsTestCase.java | 6 + .../entity/tms/TmsTestCaseFixPush.java | 83 ++++ .../entity/tms/TmsTestCaseReviewComment.java | 84 ++++ .../entity/tms/TmsTestCaseVersion.java | 7 + .../entity/tms/enums/TmsFixPushStatus.java | 15 + .../tms/enums/TmsReviewCommentState.java | 15 + .../tms/enums/TmsReviewCommentTarget.java | 14 + .../rules/exception/ErrorType.java | 5 + .../connector/CiConnectorTriggerRunTest.java | 120 ++++++ .../TestCaseFixRoundE2eIntegrationTest.java | 403 ++++++++++++++++++ .../pipeline/FixRoundIterationHooksTest.java | 147 +++++++ .../TmsTestCaseFixRoundServiceTest.java | 264 ++++++++++++ .../TmsTestCasePushToAgentServiceTest.java | 284 ++++++++++++ ...msTestCaseReadyEligibilityServiceTest.java | 82 ++++ ...sTestCaseReviewCommentServiceImplTest.java | 232 ++++++++++ .../service/TmsTestCaseServiceImplTest.java | 5 + .../db/tms/fixround/fix-round-cleanup.sql | 9 + .../db/tms/fixround/fix-round-fill.sql | 24 ++ 45 files changed, 3255 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseReviewRS.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentService.java create mode 100644 src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixPushRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixPushStatus.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentState.java create mode 100644 src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentTarget.java create mode 100644 src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java create mode 100644 src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java create mode 100644 src/test/resources/db/tms/fixround/fix-round-cleanup.sql create mode 100644 src/test/resources/db/tms/fixround/fix-round-fill.sql diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java new file mode 100644 index 0000000000..6714afa132 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java @@ -0,0 +1,20 @@ +package com.epam.reportportal.base.core.aifactory.connector; + +import java.util.Map; + +/** + * What it takes to start a brand-new run in a CI system, as opposed to running a stage again. + *

+ * Example: {@code new CiRunRequest("org/repo", "fix-test-case.yml", Map.of("test_case_id", "103", + * "push_id", "7"))} starts the workflow {@code fix-test-case.yml} of the repository {@code org/repo} with + * the two inputs. For GitLab CI {@code repo} is the project id, {@code workflowRef} the git ref, and the inputs + * become pipeline variables. + * + * @param repo the repository (GitHub) or project (GitLab) the run belongs to; it must be the one the + * project's integration is configured for + * @param workflowRef the workflow file (GitHub) or git ref (GitLab); an integration may override it with its own + * setting for this kind of run + * @param inputs what the run is told, as plain strings + */ +public record CiRunRequest(String repo, String workflowRef, Map inputs) { +} 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 index 5fd7dc7844..b3ff94e5e9 100644 --- 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 @@ -35,4 +35,18 @@ public interface CiTriggerConnector { * @return the URL of the newly triggered run */ String triggerStageRerun(Integration integration, PipelineStage stage); + + /** + * Starts a new run, not tied to any stage, e.g. the job that fixes a test case after a review. + *

+ * Example: the fix job of GitHub Actions is the workflow {@code fix-test-case.yml} started with the inputs + * {@code test_case_id} and {@code push_id}. An integration may name that workflow itself in its parameter + * {@code fixWorkflowRef}, which then wins over the one in the request. + * + * @param integration credentials/config for the system (token, base URL) + * @param request where and with what to start the run + * @return the URL where the started run can be followed (a workflow dispatch does not return the run itself, + * so this can be the page of the workflow) + */ + String triggerRun(Integration integration, CiRunRequest request); } 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 index 6b91cd7b02..22b2e31459 100644 --- 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 @@ -27,6 +27,7 @@ public class GitHubActionsCiConnector extends AbstractCiTriggerConnector { private static final String REPO_PARAM = "repo"; private static final String DEFAULT_GIT_REF = "main"; private static final String WORKFLOW_PARAM = "workflowRef"; + private static final String FIX_WORKFLOW_PARAM = "fixWorkflowRef"; private final CiHttpClient ciHttpClient; private final ObjectMapper objectMapper; @@ -78,4 +79,38 @@ public String triggerStageRerun(Integration integration, PipelineStage stage) { return "https://github.com/" + repo + "/actions/workflows/" + workflowFile; } + + @Override + public String triggerRun(Integration integration, CiRunRequest request) { + var token = requiredParam(integration, TOKEN_PARAM); + // same rule as for a rerun: the repo named by a caller never steers where the token of the integration is used + var configuredRepo = requiredParam(integration, REPO_PARAM); + if (request.repo() == null || !configuredRepo.equals(request.repo())) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "CI repo '" + request.repo() + "' does not match the repo configured for this project's GitHub Actions integration"); + } + var configuredWorkflow = stringParam(integration, FIX_WORKFLOW_PARAM); + var workflowRef = configuredWorkflow == null || configuredWorkflow.isBlank() ? request.workflowRef() : configuredWorkflow; + if (workflowRef == null || workflowRef.isBlank()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "No workflow to start: set '" + FIX_WORKFLOW_PARAM + "' on the 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/" + configuredRepo + "/actions/workflows/" + workflowFile + "/dispatches"; + + ObjectNode body = objectMapper.createObjectNode(); + body.put("ref", gitRef == null || gitRef.isBlank() ? DEFAULT_GIT_REF : gitRef); + ObjectNode inputs = body.putObject("inputs"); + request.inputs().forEach(inputs::put); + + 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/" + configuredRepo + "/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 index a8d509b4f4..2633aa7414 100644 --- 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 @@ -79,6 +79,32 @@ public String triggerStageRerun(Integration integration, PipelineStage stage) { return extractPipelineUrl(responseBody, fallbackUrl); } + @Override + public String triggerRun(Integration integration, CiRunRequest request) { + var token = requiredParam(integration, TOKEN_PARAM); + var configuredProjectId = requiredParam(integration, PROJECT_ID_PARAM); + if (request.repo() == null || !configuredProjectId.equals(request.repo())) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, + "CI project '" + request.repo() + "' does not match the project configured for this project's GitLab CI integration"); + } + if (request.workflowRef() == null || request.workflowRef().isBlank()) { + throw new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "No git ref to run the pipeline on"); + } + var baseUrl = stringParam(integration, BASE_URL_PARAM); + var effectiveBaseUrl = baseUrl == null || baseUrl.isBlank() ? DEFAULT_BASE_URL : baseUrl; + var projectId = UriUtils.encodePathSegment(configuredProjectId, StandardCharsets.UTF_8); + var url = effectiveBaseUrl + "/api/v4/projects/" + projectId + "/trigger/pipeline"; + + ObjectNode body = objectMapper.createObjectNode(); + body.put("token", token); + body.put("ref", request.workflowRef()); + ObjectNode variables = body.putObject("variables"); + request.inputs().forEach((name, value) -> variables.put(name.toUpperCase(), value)); + + var responseBody = ciHttpClient.postForStatus(url, Collections.emptyMap(), body.toString()); + return extractPipelineUrl(responseBody, effectiveBaseUrl + "/api/v4/projects/" + projectId + "/pipelines"); + } + /** * 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 diff --git a/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java b/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java new file mode 100644 index 0000000000..a5a4f844af --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java @@ -0,0 +1,143 @@ +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.TmsFixPushRS; +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRQ; +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseFixedVersionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseReviewRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; +import com.epam.reportportal.base.core.tms.pipeline.TmsTestCaseFixRoundService; +import com.epam.reportportal.base.core.tms.pipeline.TmsTestCasePushToAgentService; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseReviewCommentService; +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.responses.ApiResponse; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** + * Review of AI test cases: comments of an editor, "Push to agent", and the fixed version the agent sends back. + *

+ * Example: the editor posts a comment to {@code .../test-case/103/review/comment}, then + * {@code POST .../test-case/103/review/push}; the job reads {@code GET .../review/push/7}, fixes the case and + * answers with {@code POST .../test-case/103/fixed-version}. + */ +@RestController +@RequestMapping("/v1/project/{projectKey}/tms/test-case/{testCaseId}") +@Tag(name = "Test Case Review", description = "Review comments and fix rounds of AI test cases") +@RequiredArgsConstructor +@Valid +public class TestCaseReviewController { + + private final TmsTestCaseReviewCommentService reviewCommentService; + private final TmsTestCasePushToAgentService pushToAgentService; + private final TmsTestCaseFixRoundService fixRoundService; + private final ProjectExtractor projectExtractor; + + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @GetMapping("/review") + @Operation(summary = "Get the review of a test case", + description = "The review comments of the test case with their states, how many are not sent yet, and " + + "whether the agent is fixing the case now.") + @ApiResponse(responseCode = "200", description = "Successful operation") + public TmsTestCaseReviewRS getReview(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @AuthenticationPrincipal ReportPortalUser user) { + return reviewCommentService.getReview(projectId(user, projectKey), testCaseId); + } + + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @PostMapping("/review/comment") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Add a review comment", + description = "Comments the precondition or a step of an AI test case. Not available while the agent is " + + "fixing the case.") + @ApiResponse(responseCode = "201", description = "Comment added") + public TmsReviewCommentRS addComment(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @RequestBody @Valid TmsReviewCommentRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + return reviewCommentService.addComment(projectId(user, projectKey), testCaseId, user.getUserId(), rq); + } + + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @DeleteMapping("/review/comment/{commentId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete an own review comment", + description = "The author can delete a comment until it is pushed to the agent.") + @ApiResponse(responseCode = "204", description = "Comment deleted") + public void deleteComment(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @PathVariable("commentId") Long commentId, + @AuthenticationPrincipal ReportPortalUser user) { + reviewCommentService.deleteOwnComment(projectId(user, projectKey), testCaseId, commentId, user.getUserId()); + } + + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @DeleteMapping("/review/comment") + @Operation(summary = "Discard the unsent review comments", + description = "Removes all comments of the test case that were not pushed to the agent.") + @ApiResponse(responseCode = "200", description = "Comments discarded; the review as it is now") + public TmsTestCaseReviewRS discardComments(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @AuthenticationPrincipal ReportPortalUser user) { + var projectId = projectId(user, projectKey); + reviewCommentService.discardUnsentComments(projectId, testCaseId); + return reviewCommentService.getReview(projectId, testCaseId); + } + + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @PostMapping("/review/push") + @Operation(summary = "Push the review comments to the agent", + description = "Starts the fix job of the pipeline that produced the test case with all its unsent " + + "comments and records a fix round. If the job cannot be started nothing is recorded and the " + + "comments stay not sent.") + @ApiResponse(responseCode = "200", description = "Fix round started") + public TmsFixPushRS pushToAgent(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @AuthenticationPrincipal ReportPortalUser user) { + return pushToAgentService.push(projectId(user, projectKey), testCaseId, user.getUserId()); + } + + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @GetMapping("/review/push/{pushId}") + @Operation(summary = "Get a fix round", + description = "The fix round with the comments it carries; the fix job reads it to know what to apply.") + @ApiResponse(responseCode = "200", description = "Successful operation") + public TmsFixPushRS getPush(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @PathVariable("pushId") Long pushId, + @AuthenticationPrincipal ReportPortalUser user) { + return pushToAgentService.getPush(projectId(user, projectKey), testCaseId, pushId); + } + + @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) + @PostMapping("/fixed-version") + @Operation(summary = "Report the fixed version of a test case", + description = "Ends a fix round: the content becomes a new default version of the test case (the previous " + + "version stays as history), its grade is stored, the case goes back to Draft and each pushed " + + "comment is marked addressed or not. All in one call.") + @ApiResponse(responseCode = "200", description = "The test case after the fix") + public TmsTestCaseRS reportFixedVersion(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @RequestBody @Valid TmsTestCaseFixedVersionRQ rq, + @AuthenticationPrincipal ReportPortalUser user) { + return fixRoundService.applyFixedVersion(projectId(user, projectKey), testCaseId, rq); + } + + private long projectId(ReportPortalUser user, String projectKey) { + return projectExtractor + .extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)) + .getProjectId(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java new file mode 100644 index 0000000000..b4368ed92e --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java @@ -0,0 +1,48 @@ +package com.epam.reportportal.base.core.tms.dto; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * One fix round of a test case: what was pushed to the agent and where the round stands. + *

+ * Example: {@code {"id": 7, "testCaseId": 103, "roundNumber": 1, "status": "PENDING", "previousIterationId": 41, + * "comments": [...]}} is what the editor gets back from "Push to agent", and what the job reads to know which + * comments to apply. When the round ends, {@code status} is {@code FIXED} (with {@code resultIterationId}) or + * {@code FAILED} (with {@code failureReason}). + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TmsFixPushRS { + + private Long id; + + private Long testCaseId; + + private Integer roundNumber; + + private TmsFixPushStatus status; + + private Long previousIterationId; + + private Long resultIterationId; + + private Instant requestedAt; + + private Instant finishedAt; + + private String workflowUrl; + + private String failureReason; + + private List comments; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRQ.java new file mode 100644 index 0000000000..f1116f5126 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRQ.java @@ -0,0 +1,40 @@ +package com.epam.reportportal.base.core.tms.dto; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A new review comment on an AI test case. + *

+ * Example: {@code {"targetType": "STEP", "stepNumber": 2, "text": "Name the exact toast text"}} comments step 2; + * {@code {"targetType": "PRECONDITION", "text": "Split this case into two"}} comments the precondition. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsReviewCommentRQ { + + @NotNull + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, + description = "Where the comment is attached: PRECONDITION, STEP or SCENARIO_TEXT (the instructions / " + + "expected result block of a Text case)") + private TmsReviewCommentTarget targetType; + + @Min(1) + @Schema(description = "1-based position of the commented step; required for STEP and not allowed otherwise") + private Integer stepNumber; + + @NotBlank + @Size(max = 4000) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "What the agent should change") + private String text; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java new file mode 100644 index 0000000000..4115edc598 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java @@ -0,0 +1,48 @@ +package com.epam.reportportal.base.core.tms.dto; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * A review comment of a test case with its state. + *

+ * Example: {@code {"id": 11, "targetType": "STEP", "stepNumber": 2, "text": "Name the exact toast text", + * "state": "ADDRESSED", "roundNumber": 1}}; a comment the agent could not apply is {@code "state": + * "NOT_ADDRESSED"} with an {@code agentReason}. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TmsReviewCommentRS { + + private Long id; + + private TmsReviewCommentTarget targetType; + + private Integer stepNumber; + + private String text; + + private Long authorId; + + private Instant createdAt; + + private TmsReviewCommentState state; + + private Integer roundNumber; + + private String agentReason; + + /** + * The push that carried the comment to the agent; the agent reads the comments of its job by it. + */ + private Long fixPushId; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java new file mode 100644 index 0000000000..03f852b608 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java @@ -0,0 +1,78 @@ +package com.epam.reportportal.base.core.tms.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +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; + +/** + * What the agent sends when it has fixed a test case in a fix round: the fixed content, its fresh grade and the + * answer to each comment, in one call, so the case, its scores and its comments never disagree. + *

+ * Example: {@code {"fixPushId": 7, "pipelineIterationId": 52, "manualScenario": {...the fixed steps...}, + * "qualityScores": [{"criterionKey": "atomicity", "score": 15}], "generation": {"costUsd": 0.22}, + * "commentResults": [{"commentId": 11, "addressed": true}, {"commentId": 12, "addressed": false, + * "reason": "Needs a new case; a fix round cannot create cases"}]}}. Without {@code qualityScores} the case is + * still updated, but the round ends as "Fixed - Grade failed" and Auto-Ready does not act. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseFixedVersionRQ { + + @NotNull + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, + description = "The push (fix round) this answers; the job received it as an input") + private Long fixPushId; + + @Schema(description = "The iteration the agent reported for this round (a re-run of the one that produced the case)") + private Long pipelineIterationId; + + @NotNull + @Valid + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "The full content of the fixed case; it becomes " + + "the new default version, the previous version stays as history") + private TmsManualScenarioRQ manualScenario; + + @Size(max = 255) + @Schema(description = "New title of the case, when the agent changed it") + private String name; + + @Valid + @Schema(description = "The grade of the fixed version; empty when the grader failed") + private List qualityScores; + + @Valid + @Schema(description = "Tokens and cost of the round") + private TmsTestCaseGenerationMetadataRQ generation; + + @Valid + @Schema(description = "The answer to each pushed comment; a comment without an answer counts as not addressed") + private List commentResults; + + /** + * The answer of the agent to one pushed comment. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class CommentResult { + + @NotNull + private Long commentId; + + @Schema(description = "Whether the comment was applied") + private boolean addressed; + + @Size(max = 2000) + @Schema(description = "Why a comment could not be applied") + private String reason; + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseReviewRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseReviewRS.java new file mode 100644 index 0000000000..8db59b5d7c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseReviewRS.java @@ -0,0 +1,31 @@ +package com.epam.reportportal.base.core.tms.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Everything the review strip of a test case needs: its comments and where the review stands. + *

+ * Example: {@code {"comments": [...], "unsentCount": 2, "fixInProgress": false}} shows "Review comments - 2 not + * sent"; after the push {@code "fixInProgress": true, "fixRound": 1} shows "Agent is fixing - Fix round 1" and + * the comments are read-only. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TmsTestCaseReviewRS { + + private List comments; + + private int unsentCount; + + private boolean fixInProgress; + + private Integer fixRound; +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java new file mode 100644 index 0000000000..446cfb4f1a --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java @@ -0,0 +1,31 @@ +package com.epam.reportportal.base.core.tms.mapper; + +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRS; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; + +/** + * Turns a stored review comment into what the API returns. + *

+ * Example: a comment on step 2 that the agent could not apply comes back with {@code state NOT_ADDRESSED}, + * its {@code roundNumber} and the reason of the agent. + */ +public final class TmsReviewCommentMapper { + + private TmsReviewCommentMapper() { + } + + public static TmsReviewCommentRS toRS(TmsTestCaseReviewComment comment) { + return TmsReviewCommentRS.builder() + .id(comment.getId()) + .targetType(comment.getTargetType()) + .stepNumber(comment.getStepNumber()) + .text(comment.getCommentText()) + .authorId(comment.getAuthorId()) + .createdAt(comment.getCreatedAt()) + .state(comment.getState()) + .roundNumber(comment.getRoundNumber()) + .agentReason(comment.getAgentReason()) + .fixPushId(comment.getFixPushId()) + .build(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java new file mode 100644 index 0000000000..92b638af55 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java @@ -0,0 +1,57 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +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.PipelineStageContributor; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +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.TmsTestCase; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Labels the iteration of a fix round, so that it is not taken for a plain re-run in the list. + *

+ * Example: iteration #5 is the answer to round 1 of TC103 (it carries {@code fixPushId = 7}). This contributor + * owns no stage; it adds the iteration attribute {@code fixRound = "TC103 · round 1"} that the list shows next to + * the other facts of the iteration, and a Compare against #4 can tell the two apart. + */ +@Component +@RequiredArgsConstructor +public class FixRoundIterationContributor implements PipelineStageContributor { + + static final String FIX_ROUND_ATTRIBUTE_KEY = "fixRound"; + + private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseRepository testCaseRepository; + + @Override + public boolean supports(Pipeline pipeline) { + return TestCaseReviewContributor.PIPELINE_TYPE.equals(pipeline.getType()); + } + + @Override + public List contribute(PipelineIteration iteration, List stages) { + return List.of(); + } + + @Override + public List iterationAttributes(PipelineIteration iteration, List stages) { + return FixRoundIterationSupport.fixPushId(iteration) + .flatMap(fixPushRepository::findById) + .map(push -> { + var testCaseName = testCaseRepository.findById(push.getTestCaseId()) + .map(TmsTestCase::getDisplayId) + .orElse("Test Case " + push.getTestCaseId()); + return List.of(PipelineAttribute.builder().key(FIX_ROUND_ATTRIBUTE_KEY).label("Fix round") + .value(testCaseName + " · round " + push.getRoundNumber()) + .type(AttributeType.META).valueType(AttributeValueType.TEXT).build()); + }) + .orElse(List.of()); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java new file mode 100644 index 0000000000..ffb3a247b7 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java @@ -0,0 +1,43 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.core.aifactory.model.PipelineAttribute; +import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; +import java.util.Optional; + +/** + * How the agent tells ReportPortal that an iteration it reports is the answer to a fix round. + *

+ * Example: the fix job received {@code push_id=7}. It reports its iteration as a re-run of the one that produced + * the case, with the iteration attribute {@code {"key": "fixPushId", "value": "7", "type": "NONE"}}. From it + * ReportPortal knows which round the iteration belongs to, ends the round when the work fails, and labels the + * iteration "Fix round 1" in the list. + */ +final class FixRoundIterationSupport { + + static final String FIX_PUSH_ID_ATTRIBUTE_KEY = "fixPushId"; + + private FixRoundIterationSupport() { + } + + /** + * The id of the fix round the iteration answers, when the agent reported one. + */ + static Optional fixPushId(PipelineIteration iteration) { + if (iteration == null || iteration.getAttributes() == null || iteration.getAttributes().getItems() == null) { + return Optional.empty(); + } + return iteration.getAttributes().getItems().stream() + .filter(attribute -> FIX_PUSH_ID_ATTRIBUTE_KEY.equals(attribute.getKey())) + .map(PipelineAttribute::getValue) + .findFirst() + .flatMap(FixRoundIterationSupport::parseId); + } + + private static Optional parseId(String value) { + try { + return Optional.of(Long.parseLong(value.strip())); + } catch (NumberFormatException | NullPointerException e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java new file mode 100644 index 0000000000..fffba7682a --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java @@ -0,0 +1,34 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +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.PipelineStageReportedHandler; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Ends a fix round as failed when the iteration the agent reported for it fails. + *

+ * Example: the agent reported iteration #5 for round 1 of TC103 (attribute {@code fixPushId = 7}) and its Fix + * stage fails. The round ends as failed with the reason "Stage Fix failed", the comments of TC103 return to "not + * sent" and the editor can push again. A failed Grade stage does not count: it does not fail the iteration, and + * the round still ends with the fixed version the agent sends ("Fixed - Grade failed"). + */ +@Component +@RequiredArgsConstructor +public class FixRoundOnStageFailed implements PipelineStageReportedHandler { + + private final TmsTestCaseFixRoundService fixRoundService; + + @Override + public List onStageReported(StageReport report) { + var stage = report.stage(); + if (stage.getStatusCategory() != StatusCategory.FAILED || !stage.isAffectsIterationStatus()) { + return List.of(); + } + FixRoundIterationSupport.fixPushId(stage.getIteration()) + .ifPresent(pushId -> fixRoundService.failRound(pushId, "Stage '" + stage.getName() + "' failed")); + return List.of(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java new file mode 100644 index 0000000000..ad2a11c4a1 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java @@ -0,0 +1,56 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import java.time.Duration; +import java.time.Instant; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Ends fix rounds the agent never answered, so the comments of the case do not stay "sent" forever. + *

+ * Example: with the default timeout of 30 minutes, a round pushed at 10:00 that has no fixed version and no + * failed iteration by 10:30 ends as failed ("The agent did not answer in 30 minutes"); its comments return to + * "not sent" and the editor can push again or discard them. The timeout is set with + * {@code rp.tms.fix-push.timeout}, how often it is checked with {@code rp.tms.fix-push.check-interval}. + */ +@Slf4j +@Component +public class TmsFixPushTimeoutJob { + + private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundService fixRoundService; + private final Duration timeout; + + public TmsFixPushTimeoutJob(TmsTestCaseFixPushRepository fixPushRepository, + TmsTestCaseFixRoundService fixRoundService, + @Value("${rp.tms.fix-push.timeout:PT30M}") Duration timeout) { + this.fixPushRepository = fixPushRepository; + this.fixRoundService = fixRoundService; + this.timeout = timeout; + } + + @Scheduled(fixedDelayString = "${rp.tms.fix-push.check-interval:PT1M}", + initialDelayString = "${rp.tms.fix-push.check-interval:PT1M}") + public void failUnansweredRounds() { + failUnansweredRounds(Instant.now()); + } + + /** + * Ends every pending round that was pushed more than the timeout before {@code now}. + * + * @return how many rounds were ended + */ + int failUnansweredRounds(Instant now) { + var overdue = fixPushRepository.findByStatusAndRequestedAtBefore(TmsFixPushStatus.PENDING, now.minus(timeout)); + for (var push : overdue) { + log.warn("Fix round {} of test case {} got no answer in {}; ending it as failed", push.getId(), + push.getTestCaseId(), timeout); + fixRoundService.failRound(push.getId(), "The agent did not answer in " + timeout.toMinutes() + " minutes"); + } + return overdue.size(); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java new file mode 100644 index 0000000000..44231d2811 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java @@ -0,0 +1,150 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.NOT_FOUND; +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.TEST_CASE_REVIEW_CONFLICT; + +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseFixedVersionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; +import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseQualityService; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseVersionService; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; +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 lombok.RequiredArgsConstructor; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Ends a fix round of a test case: either with the fixed version the agent sends, or as failed. + *

+ * Example: TC103 was pushed (round 1) with two comments. The agent sends the fixed steps, a new grade of 88 and + * the answers: the case gets a new default version, becomes Draft ("Modified by agent") with the evaluation 88, + * the applied comment is Addressed (round 1) and the other is Not addressed with the reason of the agent. Then + * Auto-Ready may promote the case. If instead the job fails or never answers, {@link #failRound} returns the + * comments to "not sent" and the case keeps its previous version. + */ +@Service +@RequiredArgsConstructor +public class TmsTestCaseFixRoundService { + + static final String UNANSWERED_REASON = "The agent did not answer this comment"; + + private final TmsTestCaseRepository testCaseRepository; + private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseReviewCommentRepository commentRepository; + private final PipelineIterationRepository iterationRepository; + private final TmsTestCaseVersionService versionService; + private final TmsTestCaseQualityService qualityService; + private final TmsTestCaseService testCaseService; + private final ApplicationEventPublisher eventPublisher; + + /** + * Replaces the content of the pushed test case with the version fixed by the agent and records the outcome of + * the round, all in one transaction. + * + * @return the test case as it is after the fix + */ + @Transactional + public TmsTestCaseRS applyFixedVersion(long projectId, Long testCaseId, TmsTestCaseFixedVersionRQ rq) { + var push = fixPushRepository.findByIdForUpdate(rq.getFixPushId()) + .filter(found -> found.getProjectId().equals(projectId) && found.getTestCaseId().equals(testCaseId)) + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Fix round '" + rq.getFixPushId() + "'")); + if (push.getStatus() != TmsFixPushStatus.PENDING) { + throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, + "Fix round '" + push.getId() + "' has already ended as " + push.getStatus()); + } + var testCase = testCaseRepository.findByProjectIdAndId(projectId, testCaseId) + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Test Case '" + testCaseId + "'")); + + var newVersion = versionService.createNewDefaultVersion(projectId, testCase, rq.getManualScenario(), + rq.getPipelineIterationId()); + qualityService.applyQualityScores(projectId, newVersion, rq.getQualityScores()); + qualityService.applyGenerationMetadata(newVersion, rq.getGeneration()); + var graded = CollectionUtils.isNotEmpty(rq.getQualityScores()); + + if (rq.getName() != null && !rq.getName().isBlank()) { + testCase.setName(rq.getName()); + } + // a fixed case always goes back to review, also when it was Ready + testCase.setStatus(TmsTestCaseStatus.DRAFT); + testCase.setModifiedByAgent(true); + testCaseRepository.save(testCase); + + markCommentsAnswered(push, rq.getCommentResults()); + + push.setStatus(graded ? TmsFixPushStatus.FIXED : TmsFixPushStatus.FIXED_GRADE_FAILED); + push.setResultIterationId(rq.getPipelineIterationId()); + push.setFinishedAt(Instant.now()); + fixPushRepository.save(push); + + if (graded) { + promoteWhenAutoReadyAllows(projectId, testCaseId, push); + } + eventPublisher.publishEvent(new TmsTestCasesLifecycleChangedEvent(List.of(testCaseId))); + return testCaseService.getById(projectId, testCaseId); + } + + /** + * Ends the round as failed: the case keeps its previous version and its comments return to "not sent", so + * the editor can push again or discard them. Does nothing when the round has already ended. + */ + @Transactional + public void failRound(Long fixPushId, String reason) { + var push = fixPushRepository.findByIdForUpdate(fixPushId).orElse(null); + if (push == null || push.getStatus() != TmsFixPushStatus.PENDING) { + return; + } + for (var comment : commentRepository.findByFixPushId(push.getId())) { + comment.setState(TmsReviewCommentState.NOT_SENT); + comment.setFixPushId(null); + comment.setRoundNumber(null); + commentRepository.save(comment); + } + push.setStatus(TmsFixPushStatus.FAILED); + push.setFailureReason(reason); + push.setFinishedAt(Instant.now()); + fixPushRepository.save(push); + eventPublisher.publishEvent(new TmsTestCasesLifecycleChangedEvent(List.of(push.getTestCaseId()))); + } + + private void markCommentsAnswered(TmsTestCaseFixPush push, List results) { + Map resultsByCommentId = results == null ? Map.of() + : results.stream().collect(Collectors.toMap(TmsTestCaseFixedVersionRQ.CommentResult::getCommentId, + Function.identity(), (first, second) -> second)); + for (TmsTestCaseReviewComment comment : commentRepository.findByFixPushId(push.getId())) { + var result = resultsByCommentId.get(comment.getId()); + var addressed = result != null && result.isAddressed(); + comment.setState(addressed ? TmsReviewCommentState.ADDRESSED : TmsReviewCommentState.NOT_ADDRESSED); + comment.setRoundNumber(push.getRoundNumber()); + comment.setAgentReason(addressed ? null : (result != null && result.getReason() != null + ? result.getReason() : UNANSWERED_REASON)); + commentRepository.save(comment); + } + } + + /** + * After the update Auto-Ready runs with the settings of the pipeline that produced the case. + */ + private void promoteWhenAutoReadyAllows(long projectId, Long testCaseId, TmsTestCaseFixPush push) { + iterationRepository.findById(push.getPreviousIterationId()) + .flatMap(iteration -> AutoReadySettings.of(iteration.getPipeline())) + .filter(AutoReadySettings::active) + .ifPresent(settings -> testCaseService.applyAutoReady(projectId, testCaseId, settings.threshold())); + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java new file mode 100644 index 0000000000..2b7c920c81 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java @@ -0,0 +1,247 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.BAD_REQUEST_ERROR; +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.NOT_FOUND; +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.TEST_CASE_REVIEW_CONFLICT; + +import com.epam.reportportal.base.core.aifactory.connector.CiRunRequest; +import com.epam.reportportal.base.core.aifactory.connector.CiTriggerConnector; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.core.tms.dto.TmsFixPushRS; +import com.epam.reportportal.base.core.tms.mapper.TmsReviewCommentMapper; +import com.epam.reportportal.base.infrastructure.persistence.dao.IntegrationRepository; +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.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +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.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * "Push to agent": sends the unsent review comments of an AI test case to the factory by starting its fix job. + *

+ * Example: TC103 came out of iteration #4 and has two unsent comments. The push records round 1 as pending, + * marks the comments as sent and starts the fix workflow of the repository of iteration #4 with the inputs + * {@code test_case_id=103}, {@code push_id=7} and {@code previous_iteration_id=41}. The agent reads the comments + * of push 7 from ReportPortal, fixes the case and reports its work as a new iteration, a re-run of #4. If the job + * cannot be started, nothing is recorded and the comments stay "not sent". + *

+ * Deliberately not one transaction: the CI call in the middle must not run inside a database transaction. The + * round is recorded first (so a second push sees it in flight), then the job is started, and the record is + * undone if starting fails. + */ +@Service +public class TmsTestCasePushToAgentService { + + static final String INPUT_TEST_CASE_ID = "test_case_id"; + static final String INPUT_PUSH_ID = "push_id"; + static final String INPUT_PREVIOUS_ITERATION_ID = "previous_iteration_id"; + + private final TmsTestCaseRepository testCaseRepository; + private final TmsTestCaseReviewCommentRepository commentRepository; + private final TmsTestCaseFixPushRepository fixPushRepository; + private final PipelineStageTestCaseRepository stageTestCaseRepository; + private final PipelineIterationRepository iterationRepository; + private final PipelineStageRepository stageRepository; + private final IntegrationRepository integrationRepository; + private final Map connectorsByType; + private final TransactionTemplate transactionTemplate; + + public TmsTestCasePushToAgentService(TmsTestCaseRepository testCaseRepository, + TmsTestCaseReviewCommentRepository commentRepository, + TmsTestCaseFixPushRepository fixPushRepository, + PipelineStageTestCaseRepository stageTestCaseRepository, + PipelineIterationRepository iterationRepository, + PipelineStageRepository stageRepository, + IntegrationRepository integrationRepository, + List connectors, + PlatformTransactionManager transactionManager) { + this.testCaseRepository = testCaseRepository; + this.commentRepository = commentRepository; + this.fixPushRepository = fixPushRepository; + this.stageTestCaseRepository = stageTestCaseRepository; + this.iterationRepository = iterationRepository; + this.stageRepository = stageRepository; + this.integrationRepository = integrationRepository; + this.connectorsByType = connectors.stream() + .collect(Collectors.toMap(CiTriggerConnector::getSupportedExecutorType, Function.identity())); + this.transactionTemplate = new TransactionTemplate(transactionManager); + this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + /** + * Starts a fix round for the test case with all its unsent comments. + * + * @return the round that was started + */ + public TmsFixPushRS push(long projectId, Long testCaseId, Long userId) { + var claimed = transactionTemplate.execute(status -> claim(projectId, testCaseId, userId)); + + String workflowUrl; + try { + workflowUrl = claimed.connector().triggerRun(claimed.integration(), claimed.runRequest()); + } catch (RuntimeException e) { + transactionTemplate.executeWithoutResult(status -> release(claimed.pushId())); + throw e; + } + return transactionTemplate.execute(status -> recordStarted(claimed.pushId(), workflowUrl)); + } + + /** + * The round and the comments it carries; this is what the fix job reads. + */ + public TmsFixPushRS getPush(long projectId, Long testCaseId, Long pushId) { + var push = fixPushRepository.findById(pushId) + .filter(found -> found.getProjectId().equals(projectId) && found.getTestCaseId().equals(testCaseId)) + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Fix round '" + pushId + "'")); + return toRS(push, commentRepository.findByFixPushId(pushId)); + } + + private Claimed claim(long projectId, Long testCaseId, Long userId) { + var testCase = testCaseRepository.findByProjectIdAndId(projectId, testCaseId) + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Test Case '" + testCaseId + "'")); + assertCanBePushed(testCase); + var unsent = commentRepository.findByTestCaseIdAndState(testCaseId, TmsReviewCommentState.NOT_SENT); + if (unsent.isEmpty()) { + throw new ReportPortalException(BAD_REQUEST_ERROR, "Add at least one review comment before pushing to the agent"); + } + + var previousIterationId = findIterationThatProduced(testCaseId); + var execution = findCiExecution(previousIterationId); + var connector = connectorsByType.get(execution.getExecutorType()); + var project = iterationRepository.findById(previousIterationId).orElseThrow().getPipeline().getProject(); + var integration = resolveIntegration(project, connector); + + var push = new TmsTestCaseFixPush(); + push.setTestCaseId(testCaseId); + push.setProjectId(projectId); + push.setRoundNumber(fixPushRepository.findLastRoundNumber(testCaseId) + 1); + push.setStatus(TmsFixPushStatus.PENDING); + push.setPreviousIterationId(previousIterationId); + push.setRequestedBy(userId); + push.setRequestedAt(Instant.now()); + try { + push = fixPushRepository.saveAndFlush(push); + } catch (DataIntegrityViolationException e) { + throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, "The agent is already fixing this Test Case"); + } + for (var comment : unsent) { + comment.setState(TmsReviewCommentState.SENT); + comment.setFixPushId(push.getId()); + commentRepository.save(comment); + } + + var inputs = new LinkedHashMap(); + inputs.put(INPUT_TEST_CASE_ID, String.valueOf(testCaseId)); + inputs.put(INPUT_PUSH_ID, String.valueOf(push.getId())); + inputs.put(INPUT_PREVIOUS_ITERATION_ID, String.valueOf(previousIterationId)); + var parameters = execution.getRetryParameters(); + var runRequest = new CiRunRequest(parameters.get("repo"), parameters.get("workflowRef"), inputs); + return new Claimed(push.getId(), connector, integration, runRequest); + } + + private void assertCanBePushed(TmsTestCase testCase) { + if (testCase.getOrigin() != TmsTestCaseOrigin.AI) { + throw new ReportPortalException(BAD_REQUEST_ERROR, "Only AI test cases can be pushed to the agent"); + } + if (fixPushRepository.existsByTestCaseIdAndStatus(testCase.getId(), TmsFixPushStatus.PENDING)) { + throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, "The agent is already fixing this Test Case"); + } + } + + /** + * The latest iteration that has the test case linked to one of its stages, i.e. the one that created it or + * that last fixed it. + */ + private Long findIterationThatProduced(Long testCaseId) { + var iterationIds = stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(testCaseId)); + if (iterationIds.isEmpty()) { + throw new ReportPortalException(BAD_REQUEST_ERROR, + "This Test Case was not produced by a pipeline iteration, so there is no agent to push it to"); + } + return Collections.max(iterationIds); + } + + /** + * The first stage of the iteration that tells how to reach its CI system: it has a kind of CI that ReportPortal + * can start runs in, and the repository and workflow it ran in. + */ + private PipelineExecution findCiExecution(Long iterationId) { + return stageRepository.findByIterationIdOrderBySequenceAsc(iterationId).stream() + .map(stage -> stage.getExecution() == null ? null : stage.getExecution().getContent()) + .filter(execution -> execution != null && connectorsByType.containsKey(execution.getExecutorType()) + && execution.getRetryParameters() != null && execution.getRetryParameters().get("repo") != null) + .findFirst() + .orElseThrow(() -> new ReportPortalException(BAD_REQUEST_ERROR, + "The pipeline is not connected to CI: none of the stages of its iteration tells where it ran")); + } + + 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(BAD_REQUEST_ERROR, + "No enabled '" + typeName + "' automation integration configured for this project")); + } + + /** + * The job could not be started: the round never existed, and the comments are still "not sent". + */ + private void release(Long pushId) { + for (TmsTestCaseReviewComment comment : commentRepository.findByFixPushId(pushId)) { + comment.setState(TmsReviewCommentState.NOT_SENT); + comment.setFixPushId(null); + commentRepository.save(comment); + } + fixPushRepository.deleteById(pushId); + } + + private TmsFixPushRS recordStarted(Long pushId, String workflowUrl) { + var push = fixPushRepository.findById(pushId).orElseThrow(); + push.setWorkflowUrl(workflowUrl); + fixPushRepository.save(push); + return toRS(push, commentRepository.findByFixPushId(pushId)); + } + + private TmsFixPushRS toRS(TmsTestCaseFixPush push, List comments) { + return TmsFixPushRS.builder() + .id(push.getId()) + .testCaseId(push.getTestCaseId()) + .roundNumber(push.getRoundNumber()) + .status(push.getStatus()) + .previousIterationId(push.getPreviousIterationId()) + .resultIterationId(push.getResultIterationId()) + .requestedAt(push.getRequestedAt()) + .finishedAt(push.getFinishedAt()) + .workflowUrl(push.getWorkflowUrl()) + .failureReason(push.getFailureReason()) + .comments(comments.stream().map(TmsReviewCommentMapper::toRS).toList()) + .build(); + } + + private record Claimed(Long pushId, CiTriggerConnector connector, Integration integration, CiRunRequest runRequest) { + } +} 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 index cbc7568a84..153a93bc16 100644 --- 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 @@ -58,6 +58,8 @@ public void applyQualityScores(Long projectId, TmsTestCaseVersion version, List< score.setCriterion(criterion); score.setScore(rq.getScore()); score.setEvaluatedAt(now); + // the score points at the iteration that produced the version it grades, when there is one + score.setPipelineIterationId(version.getPipelineIterationId()); return score; }) .collect(Collectors.toList()); diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java new file mode 100644 index 0000000000..f50cf8901c --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java @@ -0,0 +1,60 @@ +package com.epam.reportportal.base.core.tms.service; + +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Tells whether a test case may be made Ready right now, either by a person (Approve) or by Auto-Ready. + *

+ * Example: TC103 has one comment that is not sent yet. Approve is rejected with "Push or discard the review + * comments first", and Auto-Ready skips it even if its score is above the threshold. The same holds while the + * agent is fixing the case (a pushed round is running), until the fixed version arrives. + */ +@Service +@RequiredArgsConstructor +public class TmsTestCaseReadyEligibilityService { + + private final TmsTestCaseReviewCommentRepository commentRepository; + private final TmsTestCaseFixPushRepository fixPushRepository; + + /** + * Which of the given test cases must not become Ready now: the ones with unsent review comments and the ones + * the agent is fixing. + */ + public Set findCasesThatCannotBecomeReady(Collection testCaseIds) { + if (testCaseIds.isEmpty()) { + return Set.of(); + } + var blocked = new HashSet(commentRepository.findTestCaseIdsWithState(testCaseIds, TmsReviewCommentState.NOT_SENT)); + blocked.addAll(fixPushRepository.findTestCaseIdsWithStatus(testCaseIds, TmsFixPushStatus.PENDING)); + return blocked; + } + + public boolean canBecomeReady(Long testCaseId) { + return findCasesThatCannotBecomeReady(List.of(testCaseId)).isEmpty(); + } + + /** + * Rejects making the test case Ready when it has unsent comments or is being fixed. + */ + public void assertCanBecomeReady(Long testCaseId) { + if (fixPushRepository.existsByTestCaseIdAndStatus(testCaseId, TmsFixPushStatus.PENDING)) { + throw new ReportPortalException(ErrorType.TEST_CASE_REVIEW_CONFLICT, + "The agent is fixing Test Case '" + testCaseId + "'; wait for the fix round to end"); + } + if (commentRepository.countByTestCaseIdAndState(testCaseId, TmsReviewCommentState.NOT_SENT) > 0) { + throw new ReportPortalException(ErrorType.TEST_CASE_REVIEW_CONFLICT, + "Push or discard the review comments of Test Case '" + testCaseId + "' first"); + } + } +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentService.java new file mode 100644 index 0000000000..5189a33d3f --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentService.java @@ -0,0 +1,37 @@ +package com.epam.reportportal.base.core.tms.service; + +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRQ; +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseReviewRS; + +/** + * Review comments of AI test cases (story US-AI-FCTRY-011): an editor comments the precondition or a step, the + * comments collect into one pending set per case until they are pushed to the agent or discarded. + *

+ * Example: the editor adds "Name the exact toast text" to step 2 of TC103; {@link #getReview} then reports one + * unsent comment, and the case cannot be approved until the comments are pushed or discarded. + */ +public interface TmsTestCaseReviewCommentService { + + /** + * The comments of the test case with their states and where the review stands. + */ + TmsTestCaseReviewRS getReview(long projectId, Long testCaseId); + + /** + * Adds a comment; only to an AI case, and not while the agent is fixing it. + */ + TmsReviewCommentRS addComment(long projectId, Long testCaseId, Long authorId, TmsReviewCommentRQ rq); + + /** + * Deletes the own comment of the user while it is not sent. + */ + void deleteOwnComment(long projectId, Long testCaseId, Long commentId, Long userId); + + /** + * Removes all unsent comments of the test case. + * + * @return how many comments were removed + */ + int discardUnsentComments(long projectId, Long testCaseId); +} diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java new file mode 100644 index 0000000000..d3ae63943d --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java @@ -0,0 +1,125 @@ +package com.epam.reportportal.base.core.tms.service; + +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.BAD_REQUEST_ERROR; +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.FORBIDDEN_OPERATION; +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.NOT_FOUND; +import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.TEST_CASE_REVIEW_CONFLICT; + +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRQ; +import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRS; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseReviewRS; +import com.epam.reportportal.base.core.tms.mapper.TmsReviewCommentMapper; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TmsTestCaseReviewCommentServiceImpl implements TmsTestCaseReviewCommentService { + + private static final String TEST_CASE_NOT_FOUND = "Test Case with id: %d for projectId: %d"; + + private final TmsTestCaseRepository testCaseRepository; + private final TmsTestCaseReviewCommentRepository commentRepository; + private final TmsTestCaseFixPushRepository fixPushRepository; + + @Override + @Transactional(readOnly = true) + public TmsTestCaseReviewRS getReview(long projectId, Long testCaseId) { + findTestCase(projectId, testCaseId); + var comments = commentRepository.findByTestCaseIdOrderByCreatedAtAscIdAsc(testCaseId).stream() + .map(TmsReviewCommentMapper::toRS) + .toList(); + var unsent = comments.stream().filter(comment -> comment.getState() == TmsReviewCommentState.NOT_SENT).count(); + var pendingPush = fixPushRepository.findByTestCaseIdAndStatus(testCaseId, TmsFixPushStatus.PENDING); + return TmsTestCaseReviewRS.builder() + .comments(comments) + .unsentCount((int) unsent) + .fixInProgress(pendingPush.isPresent()) + .fixRound(pendingPush.map(push -> push.getRoundNumber()).orElse(null)) + .build(); + } + + @Override + @Transactional + public TmsReviewCommentRS addComment(long projectId, Long testCaseId, Long authorId, TmsReviewCommentRQ rq) { + var testCase = findTestCase(projectId, testCaseId); + assertAiCase(testCase); + assertNotBeingFixed(testCaseId); + validateTarget(rq); + + var comment = new TmsTestCaseReviewComment(); + comment.setTestCaseId(testCaseId); + comment.setTargetType(rq.getTargetType()); + comment.setStepNumber(rq.getTargetType() == TmsReviewCommentTarget.STEP ? rq.getStepNumber() : null); + comment.setCommentText(rq.getText().strip()); + comment.setAuthorId(authorId); + comment.setCreatedAt(Instant.now()); + comment.setState(TmsReviewCommentState.NOT_SENT); + return TmsReviewCommentMapper.toRS(commentRepository.save(comment)); + } + + @Override + @Transactional + public void deleteOwnComment(long projectId, Long testCaseId, Long commentId, Long userId) { + findTestCase(projectId, testCaseId); + var comment = commentRepository.findById(commentId) + .filter(found -> found.getTestCaseId().equals(testCaseId)) + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Review comment '" + commentId + "'")); + if (!comment.getAuthorId().equals(userId)) { + throw new ReportPortalException(FORBIDDEN_OPERATION, + "Only the author can delete a comment; the others are removed together with 'Discard comments'"); + } + if (comment.getState() != TmsReviewCommentState.NOT_SENT) { + throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, "A comment that was pushed to the agent cannot be deleted"); + } + commentRepository.delete(comment); + } + + @Override + @Transactional + public int discardUnsentComments(long projectId, Long testCaseId) { + findTestCase(projectId, testCaseId); + assertNotBeingFixed(testCaseId); + return commentRepository.deleteByTestCaseIdAndState(testCaseId, TmsReviewCommentState.NOT_SENT); + } + + private TmsTestCase findTestCase(long projectId, Long testCaseId) { + return testCaseRepository.findByProjectIdAndId(projectId, testCaseId) + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, TEST_CASE_NOT_FOUND.formatted(testCaseId, projectId))); + } + + private void assertAiCase(TmsTestCase testCase) { + if (testCase.getOrigin() != TmsTestCaseOrigin.AI) { + throw new ReportPortalException(BAD_REQUEST_ERROR, "Review comments are available for AI test cases only"); + } + } + + private void assertNotBeingFixed(Long testCaseId) { + if (fixPushRepository.existsByTestCaseIdAndStatus(testCaseId, TmsFixPushStatus.PENDING)) { + throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, + "The agent is fixing this Test Case; its comments are read-only until the fix round ends"); + } + } + + private void validateTarget(TmsReviewCommentRQ rq) { + var isStep = rq.getTargetType() == TmsReviewCommentTarget.STEP; + if (isStep && rq.getStepNumber() == null) { + throw new ReportPortalException(BAD_REQUEST_ERROR, "stepNumber is required for a STEP comment"); + } + if (!isStep && rq.getStepNumber() != null) { + throw new ReportPortalException(BAD_REQUEST_ERROR, "stepNumber is allowed for a STEP comment only"); + } + } +} 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 ff8c666d91..9fb12331f9 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 @@ -100,6 +100,7 @@ public class TmsTestCaseServiceImpl implements TmsTestCaseService { private final ApplicationEventPublisher eventPublisher; private final TmsTestCaseActivityResourceMapper tmsTestCaseActivityResourceMapper; private final TmsTestCaseQualityService tmsTestCaseQualityService; + private final TmsTestCaseReadyEligibilityService tmsTestCaseReadyEligibilityService; private TmsTestFolderService tmsTestFolderService; private TmsTestCaseExecutionService tmsTestCaseExecutionService; @@ -199,7 +200,7 @@ public TmsTestCaseRS applyGeneration(Long projectId, Long testCaseId, TmsTestCas @Transactional public void applyAutoReady(Long projectId, Long testCaseId, int threshold) { var testCase = tmsTestCaseRepository.findByProjectIdAndId(projectId, testCaseId).orElse(null); - if (testCase == null || !isAutoReadyCandidate(testCase)) { + if (testCase == null || !isAutoReadyCandidate(testCase) || !tmsTestCaseReadyEligibilityService.canBecomeReady(testCaseId)) { return; } var defaultVersion = tmsTestCaseVersionService.getDefaultVersion(testCaseId); @@ -224,7 +225,12 @@ private boolean isAutoReadyCandidate(TmsTestCase testCase) { @Override @Transactional public int applyAutoReadyBatch(Long projectId, Collection testCases, int threshold) { - var candidates = testCases.stream().filter(this::isAutoReadyCandidate).toList(); + var blockedByReview = tmsTestCaseReadyEligibilityService.findCasesThatCannotBecomeReady( + testCases.stream().map(TmsTestCase::getId).toList()); + var candidates = testCases.stream() + .filter(this::isAutoReadyCandidate) + .filter(testCase -> !blockedByReview.contains(testCase.getId())) + .toList(); if (candidates.isEmpty()) { return 0; } @@ -349,6 +355,11 @@ public TmsTestCaseRS patch(MembershipDetails membershipDetails, return tmsTestCaseRepository .findByProjectIdAndId(projectId, testCaseId) .map((var existingTestCase) -> { + if (tmsTestCaseRQ.getStatus() == com.epam.reportportal.base.core.tms.dto.TmsTestCaseStatus.READY + && existingTestCase.getStatus() == TmsTestCaseStatus.DRAFT) { + // Approve: a Draft case with unsent review comments or a running fix round cannot be approved + tmsTestCaseReadyEligibilityService.assertCanBecomeReady(existingTestCase.getId()); + } var beforeVersion = tmsTestCaseVersionService.getDefaultVersion(existingTestCase.getId()); var before = tmsTestCaseActivityResourceMapper.buildActivityResource(existingTestCase, beforeVersion); diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionService.java index 97c321d1cb..00dfde1a3d 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionService.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseVersionService.java @@ -49,6 +49,22 @@ List createDefaultTestCaseVersionsBatch(long projectId, TmsTestCaseVersion updateDefaultTestCaseVersion(long projectId, TmsTestCase tmsTestCase, @Valid TmsManualScenarioRQ tmsManualScenarioRQ); + /** + * Makes a NEW version the default one of the test case, with the given content; the previous default version + * stays as history (with its own scores and cost), so what changed can be shown side by side. + *

+ * Example: a fix round of the agent replaces the steps of TC103: version 2 becomes the default, version 1 stays, + * and executions made before keep pointing at version 1. + * + * @param projectId project id + * @param tmsTestCase test case entity + * @param tmsManualScenarioRQ the full content of the new version + * @param pipelineIterationId the pipeline iteration that produced the version, or null + * @return the new default version + */ + TmsTestCaseVersion createNewDefaultVersion(long projectId, TmsTestCase tmsTestCase, + @Valid TmsManualScenarioRQ tmsManualScenarioRQ, Long pipelineIterationId); + /** * Patches default test case version. * 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 5090d23066..ab3c8efa2b 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 @@ -115,6 +115,28 @@ public TmsTestCaseVersion updateDefaultTestCaseVersion(long projectId, TmsTestCa .orElseGet(() -> createDefaultTestCaseVersion(projectId, tmsTestCase, tmsManualScenarioRQ)); } + @Override + @Transactional + public TmsTestCaseVersion createNewDefaultVersion(long projectId, TmsTestCase tmsTestCase, + @Valid TmsManualScenarioRQ tmsManualScenarioRQ, Long pipelineIterationId) { + var previousDefaultVersion = getDefaultVersion(tmsTestCase.getId()); + // only one version per test case may be the default one, so the old one is released first + previousDefaultVersion.setDefault(false); + tmsTestCaseVersionRepository.saveAndFlush(previousDefaultVersion); + + var newDefaultVersion = tmsTestCaseVersionMapper.createDefaultTestCaseVersion(); + newDefaultVersion.setName(previousDefaultVersion.getName()); + newDefaultVersion.setTestCase(tmsTestCase); + newDefaultVersion.setPipelineIterationId(pipelineIterationId); + newDefaultVersion = tmsTestCaseVersionRepository.save(newDefaultVersion); + + var manualScenario = tmsManualScenarioService + .createTmsManualScenario(projectId, newDefaultVersion, tmsManualScenarioRQ); + newDefaultVersion.setManualScenario(manualScenario); + manualScenario.setTestCaseVersion(newDefaultVersion); + return newDefaultVersion; + } + @Override @Transactional public TmsTestCaseVersion patchDefaultTestCaseVersion(long projectId, TmsTestCase tmsTestCase, 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 4f37f92146..2ecf0547e9 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 @@ -69,6 +69,7 @@ private StatusCodeMapping() { put(ErrorType.USER_ALREADY_ASSIGNED, HttpStatus.CONFLICT); put(ErrorType.PIPELINE_STAGE_RETRY_CONFLICT, HttpStatus.CONFLICT); put(ErrorType.IDEMPOTENCY_KEY_CONFLICT, HttpStatus.CONFLICT); + put(ErrorType.TEST_CASE_REVIEW_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/dao/tms/TmsTestCaseFixPushRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixPushRepository.java new file mode 100644 index 0000000000..a647a1588b --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixPushRepository.java @@ -0,0 +1,44 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import jakarta.persistence.LockModeType; +import java.time.Instant; +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 TmsTestCaseFixPushRepository extends JpaRepository { + + Optional findByTestCaseIdAndStatus(Long testCaseId, TmsFixPushStatus status); + + boolean existsByTestCaseIdAndStatus(Long testCaseId, TmsFixPushStatus status); + + List findByTestCaseIdOrderByRoundNumberAsc(Long testCaseId); + + @Query("SELECT COALESCE(MAX(p.roundNumber), 0) FROM TmsTestCaseFixPush p WHERE p.testCaseId = :testCaseId") + int findLastRoundNumber(@Param("testCaseId") Long testCaseId); + + /** + * The ids of the test cases among the given ones that have a round in the given status. + */ + @Query("SELECT p.testCaseId FROM TmsTestCaseFixPush p WHERE p.status = :status AND p.testCaseId IN :testCaseIds") + List findTestCaseIdsWithStatus(@Param("testCaseIds") Collection testCaseIds, + @Param("status") TmsFixPushStatus status); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM TmsTestCaseFixPush p WHERE p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + /** + * Rounds in the given status that were requested before the given moment, e.g. the pending ones the agent + * did not answer in time. + */ + List findByStatusAndRequestedAtBefore(TmsFixPushStatus status, Instant requestedBefore); +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java new file mode 100644 index 0000000000..ea4bf2ca18 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java @@ -0,0 +1,35 @@ +package com.epam.reportportal.base.infrastructure.persistence.dao.tms; + +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TmsTestCaseReviewCommentRepository extends JpaRepository { + + List findByTestCaseIdOrderByCreatedAtAscIdAsc(Long testCaseId); + + List findByTestCaseIdAndState(Long testCaseId, TmsReviewCommentState state); + + List findByFixPushId(Long fixPushId); + + long countByTestCaseIdAndState(Long testCaseId, TmsReviewCommentState state); + + /** + * The ids of the test cases among the given ones that have at least one comment in the given state. + */ + @Query("SELECT DISTINCT c.testCaseId FROM TmsTestCaseReviewComment c " + + "WHERE c.state = :state AND c.testCaseId IN :testCaseIds") + List findTestCaseIdsWithState(@Param("testCaseIds") Collection testCaseIds, + @Param("state") TmsReviewCommentState state); + + @Modifying + @Query("DELETE FROM TmsTestCaseReviewComment c WHERE c.testCaseId = :testCaseId AND c.state = :state") + int deleteByTestCaseIdAndState(@Param("testCaseId") Long testCaseId, @Param("state") TmsReviewCommentState state); +} 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 58c7922f38..92c8bec075 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 @@ -76,6 +76,12 @@ public class TmsTestCase implements Serializable { @Column(name = "status", nullable = false) private TmsTestCaseStatus status = TmsTestCaseStatus.READY; + /** + * Set once a fix round of the agent has changed the case ("Modified by agent"). + */ + @Column(name = "modified_by_agent", nullable = false) + private boolean modifiedByAgent; + @ManyToOne @JoinColumn(name = "project_id", nullable = false) private Project project; diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java new file mode 100644 index 0000000000..bd01366c52 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java @@ -0,0 +1,83 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +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; + +/** + * One "Push to agent" of one test case, i.e. one fix round of that case. + *

+ * Example: TC103 came out of iteration #4. The editor comments it and pushes: round 1 starts as {@code PENDING} + * with {@code previousIterationId} = the id of iteration #4. The agent reports iteration #5 as a re-run of #4, + * sends the fixed version of TC103 and the round ends {@code FIXED}. Rounds are numbered per test case; a case + * has at most one {@code PENDING} round. + */ +@Entity +@Table(name = "tms_test_case_fix_push", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseFixPush implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "test_case_id", nullable = false) + private Long testCaseId; + + @Column(name = "project_id", nullable = false) + private Long projectId; + + @Column(name = "round_number", nullable = false) + private Integer roundNumber; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private TmsFixPushStatus status; + + /** + * The iteration that produced the case; the agent reports its fix as a re-run of it. + */ + @Column(name = "previous_iteration_id", nullable = false) + private Long previousIterationId; + + /** + * The iteration the agent reported for this round, once it has. + */ + @Column(name = "result_iteration_id") + private Long resultIterationId; + + @Column(name = "requested_by", nullable = false) + private Long requestedBy; + + @Column(name = "requested_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant requestedAt; + + @Column(name = "finished_at") + @Convert(converter = JpaInstantConverter.class) + private Instant finishedAt; + + @Column(name = "workflow_url", columnDefinition = "TEXT") + private String workflowUrl; + + @Column(name = "failure_reason", columnDefinition = "TEXT") + private String failureReason; +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java new file mode 100644 index 0000000000..7cbdcb96bf --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java @@ -0,0 +1,84 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms; + +import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; +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; + +/** + * A comment of a reviewer on the precondition or a step of an AI-generated test case, written for the agent. + *

+ * Example: the editor comments step 2 of TC103 with "Name the exact toast text". It is a {@code STEP} comment + * with step number 2, {@code NOT_SENT} until the editor pushes the case to the agent. When the fix round ends + * the comment is {@code ADDRESSED} (round 1), or {@code NOT_ADDRESSED} with the reason given by the agent, e.g. + * "Needs a new case; a fix round cannot create cases". + */ +@Entity +@Table(name = "tms_test_case_review_comment", schema = "public") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TmsTestCaseReviewComment implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "test_case_id", nullable = false) + private Long testCaseId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private TmsReviewCommentTarget targetType; + + /** + * 1-based position of the commented step; only for {@link TmsReviewCommentTarget#STEP}. + */ + @Column(name = "step_number") + private Integer stepNumber; + + @Column(name = "comment_text", nullable = false, columnDefinition = "TEXT") + private String commentText; + + @Column(name = "author_id", nullable = false) + private Long authorId; + + @Column(name = "created_at", nullable = false) + @Convert(converter = JpaInstantConverter.class) + private Instant createdAt; + + @Enumerated(EnumType.STRING) + @Column(name = "state", nullable = false) + private TmsReviewCommentState state = TmsReviewCommentState.NOT_SENT; + + /** + * The push that carried the comment to the agent. + */ + @Column(name = "fix_push_id") + private Long fixPushId; + + @Column(name = "round_number") + private Integer roundNumber; + + /** + * The answer of the agent to a comment it could not apply. + */ + @Column(name = "agent_reason", columnDefinition = "TEXT") + private String agentReason; +} 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 067c2e2637..a50b3fd1e8 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 @@ -48,6 +48,13 @@ public class TmsTestCaseVersion implements Serializable { @OneToOne(mappedBy = "testCaseVersion") private TmsManualScenario manualScenario; + /** + * The pipeline iteration that produced this version: the one that uploaded the case, or the one of a fix + * round. Plain id, the Pipeline domain stays independent of TMS. + */ + @Column(name = "pipeline_iteration_id") + private Long pipelineIterationId; + @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/enums/TmsFixPushStatus.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixPushStatus.java new file mode 100644 index 0000000000..c813ca9466 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixPushStatus.java @@ -0,0 +1,15 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums; + +/** + * Where a fix round (one "Push to agent" of one test case) stands. + *

+ * Example: a push is {@code PENDING} while the agent works; it ends {@code FIXED} when the new version of the + * case arrives with a fresh grade, {@code FIXED_GRADE_FAILED} when the version arrives without one, or + * {@code FAILED} when the job fails or does not answer in time. + */ +public enum TmsFixPushStatus { + PENDING, + FIXED, + FIXED_GRADE_FAILED, + FAILED +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentState.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentState.java new file mode 100644 index 0000000000..c4e0b22d1a --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentState.java @@ -0,0 +1,15 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums; + +/** + * Where a review comment stands on its way to the agent and back. + *

+ * Example: a new comment is {@code NOT_SENT}; "Push to agent" makes it {@code SENT} (the agent is fixing); when + * the fix round ends it is {@code ADDRESSED} or {@code NOT_ADDRESSED} (with the reason of the agent). A failed + * round returns its comments to {@code NOT_SENT}. + */ +public enum TmsReviewCommentState { + NOT_SENT, + SENT, + ADDRESSED, + NOT_ADDRESSED +} diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentTarget.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentTarget.java new file mode 100644 index 0000000000..9f1fe9a041 --- /dev/null +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsReviewCommentTarget.java @@ -0,0 +1,14 @@ +package com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums; + +/** + * The part of a test case a review comment is attached to. + *

+ * Example: on a case with the Steps template a comment on step 2 is {@code STEP} with step number 2 and a + * comment on the precondition row is {@code PRECONDITION}; on a Text case the instructions / expected result + * block is {@code SCENARIO_TEXT}. + */ +public enum TmsReviewCommentTarget { + PRECONDITION, + STEP, + SCENARIO_TEXT +} 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 c3c6530397..95b0fed330 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 @@ -361,6 +361,11 @@ public enum ErrorType { */ IDEMPOTENCY_KEY_CONFLICT(40914, "Idempotency key conflict. {}"), + /** + * The test case is in a state that does not allow the requested review step (e.g. the agent is already fixing it) + */ + TEST_CASE_REVIEW_CONFLICT(40915, "Test case review conflict. {}"), + /** * Unable remove resource from favorites */ diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java new file mode 100644 index 0000000000..046902e5c0 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.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.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.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Starting a brand-new run (the fix job of a test case), as opposed to running a stage again. + */ +class CiConnectorTriggerRunTest { + + private final CiHttpClient ciHttpClient = mock(CiHttpClient.class); + private final GitHubActionsCiConnector gitHub = new GitHubActionsCiConnector(ciHttpClient, new ObjectMapper()); + private final GitLabCiConnector gitLab = new GitLabCiConnector(ciHttpClient, new ObjectMapper()); + + private Integration integrationWithParams(Map params) { + var integration = new Integration(); + integration.setParams(new IntegrationParams(params)); + return integration; + } + + private CiRunRequest fixRunRequest(String repo, String workflowRef) { + var inputs = new LinkedHashMap(); + inputs.put("test_case_id", "103"); + inputs.put("push_id", "7"); + return new CiRunRequest(repo, workflowRef, inputs); + } + + @Test + void gitHubTriggerRun_ShouldDispatchTheWorkflowWithTheInputsAndReturnTheWorkflowPage() { + var integration = integrationWithParams(Map.of("token", "gh-token", "repo", "org/repo")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + + var url = gitHub.triggerRun(integration, fixRunRequest("org/repo", "fix-test-case.yml")); + + assertEquals("https://github.com/org/repo/actions/workflows/fix-test-case.yml", url); + 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/fix-test-case.yml/dispatches", + urlCaptor.getValue()); + assertEquals("Bearer gh-token", headersCaptor.getValue().get("Authorization")); + assertTrue(bodyCaptor.getValue().contains("\"ref\":\"main\"")); + assertTrue(bodyCaptor.getValue().contains("\"test_case_id\":\"103\"")); + assertTrue(bodyCaptor.getValue().contains("\"push_id\":\"7\"")); + } + + @Test + void gitHubTriggerRun_WhenTheIntegrationNamesTheFixWorkflow_ShouldUseItInsteadOfTheRequestedOne() { + var integration = integrationWithParams( + Map.of("token", "gh-token", "repo", "org/repo", "fixWorkflowRef", "fix.yml")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + + var url = gitHub.triggerRun(integration, fixRunRequest("org/repo", "ai-pipeline.yml")); + + assertEquals("https://github.com/org/repo/actions/workflows/fix.yml", url); + } + + @Test + void gitHubTriggerRun_WhenTheRepoIsNotTheConfiguredOne_ShouldRefuseWithoutCallingGitHub() { + var integration = integrationWithParams(Map.of("token", "gh-token", "repo", "org/repo")); + + assertThrows(ReportPortalException.class, + () -> gitHub.triggerRun(integration, fixRunRequest("someone-else/repo", "fix.yml"))); + + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void gitHubTriggerRun_WithoutAnyWorkflow_ShouldRefuseWithoutCallingGitHub() { + var integration = integrationWithParams(Map.of("token", "gh-token", "repo", "org/repo")); + + assertThrows(ReportPortalException.class, () -> gitHub.triggerRun(integration, fixRunRequest("org/repo", null))); + + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } + + @Test + void gitLabTriggerRun_ShouldTriggerThePipelineWithTheInputsAsVariablesAndReturnItsPage() { + var integration = integrationWithParams(Map.of("triggerToken", "glptt", "projectId", "42")); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())) + .thenReturn("{\"web_url\":\"https://gitlab.com/org/repo/-/pipelines/9\"}"); + + var url = gitLab.triggerRun(integration, fixRunRequest("42", "main")); + + assertEquals("https://gitlab.com/org/repo/-/pipelines/9", url); + var urlCaptor = ArgumentCaptor.forClass(String.class); + var bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(ciHttpClient).postForStatus(urlCaptor.capture(), anyMap(), bodyCaptor.capture()); + assertEquals("https://gitlab.com/api/v4/projects/42/trigger/pipeline", urlCaptor.getValue()); + assertTrue(bodyCaptor.getValue().contains("\"ref\":\"main\"")); + assertTrue(bodyCaptor.getValue().contains("\"TEST_CASE_ID\":\"103\"")); + assertTrue(bodyCaptor.getValue().contains("\"PUSH_ID\":\"7\"")); + } + + @Test + void gitLabTriggerRun_WhenTheProjectIsNotTheConfiguredOne_ShouldRefuseWithoutCallingGitLab() { + var integration = integrationWithParams(Map.of("triggerToken", "glptt", "projectId", "42")); + + assertThrows(ReportPortalException.class, () -> gitLab.triggerRun(integration, fixRunRequest("43", "main"))); + + verify(ciHttpClient, never()).postForStatus(anyString(), anyMap(), anyString()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java new file mode 100644 index 0000000000..f4bbd1bb5e --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java @@ -0,0 +1,403 @@ +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.assertNotEquals; +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.atLeastOnce; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +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.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.core.aifactory.connector.CiHttpClient; +import com.epam.reportportal.base.ws.BaseMvcTest; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.TimeZone; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +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 review of an AI test case from the first comment to the fixed case, request by request, against the real + * application (stories US-AI-FCTRY-011 and US-AI-FCTRY-012): an AI Draft case that a generation iteration produced, + * two review comments, "Push to agent", the agent reporting its work as a new iteration that re-runs the first + * one, the fixed version arriving with its grade and the answers to the comments, Auto-Ready promoting the case, + * and a second round whose job fails. + *

+ * The CI system is the only thing that is faked: starting the fix workflow is a recorded call. 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/tms/fixround/fix-round-fill.sql") +@Sql(scripts = "/db/tms/fixround/fix-round-cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +class TestCaseFixRoundE2eIntegrationTest 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 = 920; + private static final String[] CRITERIA = {"atomicity", "clear_steps", "clear_expected_results", "no_invented_logic", + "no_invented_ui", "coherence"}; + + 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 times every time an entity 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); + } + + @MockBean + private CiHttpClient ciHttpClient; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + private long testCaseId; + private String displayId; + private long pipelineId; + private long firstIterationId; + private long firstVersionId; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void fullScenario_FromTheFirstCommentToTheFixedCaseAndAFailedRound() throws Exception { + step1_aGenerationIterationProducedAnAiDraftCase(); + step2_editorCommentsAndCannotApproveWhileCommentsAreUnsent(); + step3_pushToAgentStartsTheFixJobWithIdsOnly(); + step4_whileTheAgentIsFixingTheCaseIsLocked(); + step5_theJobReadsItsCommentsAndReportsItsIteration(); + step6_theFixedVersionArrivesAndAutoReadyPromotesTheCase(); + step7_theFixedCaseKeepsItsHistoryAndTheCommentsAreAnswered(); + step8_theIterationsKnowTheRound(); + step9_commentsNeverChangeTheLifecycleAndAFailedRoundGivesTheCommentsBack(); + } + + // ------------------------------------------------------------------ step 1 + + private void step1_aGenerationIterationProducedAnAiDraftCase() throws Exception { + var created = send(post(TEST_CASES), """ + {"name": "Test Case Library. Bulk selection. 'Add to Test Plan' is disabled when nothing is selected", + "priority": "HIGH", "testFolderId": %d, "status": "DRAFT"} + """.formatted(LIBRARY_FOLDER_ID), HttpStatus.OK); + testCaseId = created.get("id").asLong(); + displayId = created.get("displayId").asText(); + reportGrade(testCaseId, new int[] {15, 12, 14, 13, 10, 8}, "0.235"); + + var iteration = send(post(PIPELINES + "/iteration").header("Idempotency-Key", "fix-round-gen-1"), """ + {"name": "Test case generation", "type": "TEST_CASE_GENERATION", + "iteration": {"startedAt": "2026-10-20T10:00:00Z", + "attributes": [{"key": "requirement", "label": "Requirement", "value": "US-TMS-BLK-001", "type": "META"}], + "stages": [ + {"stageKey": "create", "name": "Create", "sequence": 1, "status": "Passed", "execution": %s}, + {"stageKey": "upload", "name": "Upload", "sequence": 2, "status": "Passed", "execution": %s, + "sections": [%s]} + ]}} + """.formatted(execution("101"), execution("102"), uploadReport(displayId)), HttpStatus.CREATED); + firstIterationId = iteration.get("id").asLong(); + pipelineId = iteration.get("pipelineId").asLong(); + + var review = stage(iteration, "Review"); + assertEquals("0/1 Ready", review.get("summary").asText()); + var testCase = libraryCase(); + assertEquals("AI", testCase.get("origin").asText()); + assertEquals("DRAFT", testCase.get("status").asText()); + assertEquals(72, testCase.get("metrics").get("overallScore").asInt()); + firstVersionId = testCase.get("defaultVersionId").asLong(); + + send(patch(PIPELINES + "/" + pipelineId), + "{\"settings\": {\"autoReady\": {\"enabled\": true, \"threshold\": 90}}}", HttpStatus.OK); + } + + // ------------------------------------------------------------------ step 2 + + private void step2_editorCommentsAndCannotApproveWhileCommentsAreUnsent() throws Exception { + var first = send(post(reviewUrl("/comment")), + "{\"targetType\": \"STEP\", \"stepNumber\": 2, \"text\": \"Name the exact toast text\"}", HttpStatus.CREATED); + var second = send(post(reviewUrl("/comment")), + "{\"targetType\": \"PRECONDITION\", \"text\": \"Split this case into two\"}", HttpStatus.CREATED); + assertEquals("NOT_SENT", first.get("state").asText()); + assertEquals(2, first.get("stepNumber").asInt()); + assertFalse(second.has("stepNumber")); + + var review = send(get(reviewUrl("")), null, HttpStatus.OK); + assertEquals(2, review.get("comments").size()); + assertEquals(2, review.get("unsentCount").asInt()); + assertFalse(review.get("fixInProgress").asBoolean()); + + send(patch(TEST_CASES + "/" + testCaseId), "{\"status\": \"READY\"}", HttpStatus.CONFLICT); + assertEquals("DRAFT", libraryCase().get("status").asText(), "Approve is blocked while comments are not sent"); + } + + // ------------------------------------------------------------------ step 3 + + private void step3_pushToAgentStartsTheFixJobWithIdsOnly() throws Exception { + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + + var round = send(post(reviewUrl("/push")), null, HttpStatus.OK); + + assertEquals(1, round.get("roundNumber").asInt()); + assertEquals("PENDING", round.get("status").asText()); + assertEquals(firstIterationId, round.get("previousIterationId").asLong()); + assertEquals(2, round.get("comments").size()); + assertEquals("SENT", round.get("comments").get(0).get("state").asText()); + assertEquals("https://github.com/org/repo/actions/workflows/fix-test-case.yml", round.get("workflowUrl").asText()); + + var url = ArgumentCaptor.forClass(String.class); + var body = ArgumentCaptor.forClass(String.class); + verify(ciHttpClient, atLeastOnce()).postForStatus(url.capture(), anyMap(), body.capture()); + assertEquals("https://api.github.com/repos/org/repo/actions/workflows/fix-test-case.yml/dispatches", url.getValue()); + var inputs = mapper.readTree(body.getValue()).get("inputs"); + assertEquals(String.valueOf(testCaseId), inputs.get("test_case_id").asText()); + assertEquals(round.get("id").asText(), inputs.get("push_id").asText()); + assertEquals(String.valueOf(firstIterationId), inputs.get("previous_iteration_id").asText()); + assertEquals(3, inputs.size(), "the comments themselves never travel through the workflow inputs"); + } + + // ------------------------------------------------------------------ step 4 + + private void step4_whileTheAgentIsFixingTheCaseIsLocked() throws Exception { + var review = send(get(reviewUrl("")), null, HttpStatus.OK); + assertTrue(review.get("fixInProgress").asBoolean()); + assertEquals(1, review.get("fixRound").asInt()); + assertEquals(0, review.get("unsentCount").asInt()); + + send(post(reviewUrl("/comment")), "{\"targetType\": \"PRECONDITION\", \"text\": \"one more\"}", HttpStatus.CONFLICT); + send(delete(reviewUrl("/comment")), null, HttpStatus.CONFLICT); + send(post(reviewUrl("/push")), null, HttpStatus.CONFLICT); + send(patch(TEST_CASES + "/" + testCaseId), "{\"status\": \"READY\"}", HttpStatus.CONFLICT); + } + + // ------------------------------------------------------------------ step 5 + + private long pushId; + private long secondIterationId; + private JsonNode pushedComments; + + private void step5_theJobReadsItsCommentsAndReportsItsIteration() throws Exception { + pushId = send(get(reviewUrl("")), null, HttpStatus.OK).get("comments").get(0).get("fixPushId").asLong(); + var round = send(get(reviewUrl("/push/" + pushId)), null, HttpStatus.OK); + pushedComments = round.get("comments"); + assertEquals(2, pushedComments.size()); + assertEquals("Name the exact toast text", pushedComments.get(0).get("text").asText()); + + var iteration = send(post(PIPELINES + "/iteration").header("Idempotency-Key", "fix-round-fix-1"), """ + {"name": "Test case generation", "type": "TEST_CASE_GENERATION", + "iteration": {"startedAt": "2026-10-20T11:00:00Z", "rerunOfIterationId": %d, + "attributes": [{"key": "requirement", "label": "Requirement", "value": "US-TMS-BLK-001", "type": "META"}, + {"key": "fixPushId", "label": "Fix round", "value": "%d", "type": "NONE"}], + "stages": [{"stageKey": "fix", "name": "Fix", "sequence": 1, "status": "Running", "execution": %s}]}} + """.formatted(firstIterationId, pushId, execution("201")), HttpStatus.CREATED); + secondIterationId = iteration.get("id").asLong(); + assertEquals(firstIterationId, iteration.get("rerunOf").get("id").asLong()); + } + + // ------------------------------------------------------------------ step 6 + + private void step6_theFixedVersionArrivesAndAutoReadyPromotesTheCase() throws Exception { + var fixed = send(post(TEST_CASES + "/" + testCaseId + "/fixed-version"), """ + {"fixPushId": %d, "pipelineIterationId": %d, "name": "Test Case Library. Bulk selection. 'Add to Test Plan' is disabled with no selection", + "manualScenario": {"manualScenarioType": "STEPS", + "steps": [{"instructions": "Open the Library and select no Test Case", "expectedResult": "The bulk panel is hidden"}, + {"instructions": "Look at the toast", "expectedResult": "The toast says 'Select at least one Test Case'"}]}, + "qualityScores": %s, + "generation": {"model": "auto (default)", "costUsd": 0.22}, + "commentResults": [{"commentId": %d, "addressed": true}, + {"commentId": %d, "addressed": false, "reason": "Needs a new case; a fix round cannot create cases"}]} + """.formatted(pushId, secondIterationId, scores(new int[] {15, 20, 18, 18, 14, 9}), + pushedComments.get(0).get("id").asLong(), pushedComments.get(1).get("id").asLong()), HttpStatus.OK); + + assertEquals(94, fixed.get("metrics").get("overallScore").asInt()); + assertEquals("READY", fixed.get("status").asText(), "94 >= 90: promoted by Auto-Ready after the update"); + assertNotEquals(firstVersionId, fixed.get("defaultVersionId").asLong(), "the fix is a new default version"); + assertEquals(2, fixed.get("manualScenario").get("steps").size()); + send(post(TEST_CASES + "/" + testCaseId + "/fixed-version"), "{\"fixPushId\": " + pushId + ", \"manualScenario\": " + + "{\"manualScenarioType\": \"STEPS\", \"steps\": []}}", HttpStatus.CONFLICT); + } + + // ------------------------------------------------------------------ step 7 + + private void step7_theFixedCaseKeepsItsHistoryAndTheCommentsAreAnswered() throws Exception { + assertEquals(2, count("select count(*) from tms_test_case_version where test_case_id = ?", testCaseId)); + assertEquals(1, count("select count(*) from tms_test_case_version where test_case_id = ? and is_default", testCaseId)); + assertEquals(6, count("select count(*) from tms_test_case_quality_score where test_case_version_id = ?", firstVersionId), + "the grade of the first version stays: that is the score before the fix"); + assertEquals(secondIterationId, jdbcTemplate.queryForObject( + "select pipeline_iteration_id from tms_test_case_version where test_case_id = ? and is_default", Long.class, + testCaseId)); + assertEquals(secondIterationId, jdbcTemplate.queryForObject( + "select max(s.pipeline_iteration_id) from tms_test_case_quality_score s join tms_test_case_version v " + + "on v.id = s.test_case_version_id where v.test_case_id = ?", Long.class, testCaseId)); + assertEquals(true, jdbcTemplate.queryForObject("select modified_by_agent from tms_test_case where id = ?", + Boolean.class, testCaseId)); + + var review = send(get(reviewUrl("")), null, HttpStatus.OK); + assertFalse(review.get("fixInProgress").asBoolean()); + assertEquals(0, review.get("unsentCount").asInt()); + var comments = review.get("comments"); + assertEquals("ADDRESSED", comments.get(0).get("state").asText()); + assertEquals(1, comments.get(0).get("roundNumber").asInt()); + assertEquals("NOT_ADDRESSED", comments.get(1).get("state").asText()); + assertEquals("Needs a new case; a fix round cannot create cases", comments.get(1).get("agentReason").asText()); + } + + // ------------------------------------------------------------------ step 8 + + private void step8_theIterationsKnowTheRound() throws Exception { + var second = send(get(PIPELINES + "/iteration/" + secondIterationId), null, HttpStatus.OK); + assertEquals(displayId + " · round 1", attribute(second, "fixRound"), "a fix iteration is told from a plain re-run"); + assertEquals(firstIterationId, second.get("rerunOf").get("id").asLong()); + + var uploaded = send(put(PIPELINES + "/iteration/" + secondIterationId + "/stage/upload"), """ + {"name": "Upload", "sequence": 2, "status": "Passed", "execution": %s, "sections": [%s]} + """.formatted(execution("202"), uploadReport(displayId)), HttpStatus.OK); + assertEquals("1/1 Ready", stage(uploaded, "Review").get("summary").asText()); + + var first = send(get(PIPELINES + "/iteration/" + firstIterationId), null, HttpStatus.OK); + assertEquals("1/1 Ready", stage(first, "Review").get("summary").asText(), + "the first iteration recounts its review: its case went Ready"); + } + + // ------------------------------------------------------------------ step 9 + + private void step9_commentsNeverChangeTheLifecycleAndAFailedRoundGivesTheCommentsBack() throws Exception { + send(post(reviewUrl("/comment")), "{\"targetType\": \"STEP\", \"stepNumber\": 1, \"text\": \"Too vague\"}", + HttpStatus.CREATED); + assertEquals("READY", libraryCase().get("status").asText(), "adding a comment does not change the lifecycle"); + reset(ciHttpClient); + when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); + + var round = send(post(reviewUrl("/push")), null, HttpStatus.OK); + assertEquals(2, round.get("roundNumber").asInt()); + assertEquals(secondIterationId, round.get("previousIterationId").asLong(), + "round 2 follows the iteration that last fixed the case"); + var secondPushId = round.get("id").asLong(); + + var third = send(post(PIPELINES + "/iteration").header("Idempotency-Key", "fix-round-fix-2"), """ + {"name": "Test case generation", "type": "TEST_CASE_GENERATION", + "iteration": {"startedAt": "2026-10-20T12:00:00Z", "rerunOfIterationId": %d, + "attributes": [{"key": "fixPushId", "value": "%d", "type": "NONE"}], + "stages": [{"stageKey": "fix", "name": "Fix", "sequence": 1, "status": "Failed", + "message": "The agent could not apply the comment"}]}} + """.formatted(secondIterationId, secondPushId), HttpStatus.CREATED); + assertEquals("FAILED", third.get("statusCategory").asText()); + + var review = send(get(reviewUrl("")), null, HttpStatus.OK); + assertFalse(review.get("fixInProgress").asBoolean()); + assertEquals(1, review.get("unsentCount").asInt(), "the comment of the failed round is not sent again"); + assertEquals("READY", libraryCase().get("status").asText(), "a failed round leaves the case as it was"); + assertEquals("FAILED", jdbcTemplate.queryForObject("select status from tms_test_case_fix_push where id = ?", + String.class, secondPushId)); + assertEquals(2, count("select count(*) from tms_test_case_version where test_case_id = ?", testCaseId), + "a failed round creates no version"); + + send(post(reviewUrl("/push")), null, HttpStatus.OK); + send(delete(reviewUrl("/comment")), null, HttpStatus.CONFLICT); + } + + // ------------------------------------------------------------------ building requests + + private String reviewUrl(String path) { + return TEST_CASES + "/" + testCaseId + "/review" + path; + } + + private void reportGrade(long id, int[] scores, String cost) throws Exception { + send(post(TEST_CASES + "/" + id + "/generation"), "{\"qualityScores\": " + scores(scores) + + ", \"generation\": {\"model\": \"auto (default)\", \"costUsd\": " + cost + "}}", HttpStatus.OK); + } + + private String scores(int[] scores) { + var array = mapper.createArrayNode(); + for (int i = 0; i < CRITERIA.length; i++) { + array.addObject().put("criterionKey", CRITERIA[i]).put("score", scores[i]); + } + return array.toString(); + } + + private static String execution(String jobId) { + return """ + {"executorType": "GITHUB_ACTIONS", "externalRunId": "%s", "retryable": true, + "runUrl": "https://github.com/org/repo/actions/runs/%s", + "retryParameters": {"repo": "org/repo", "workflowRef": "ai-pipeline.yml"}} + """.formatted(jobId, jobId); + } + + private static String uploadReport(String testCaseDisplayId) { + return """ + {"key": "uploadReport", "title": "Upload report", "role": "RESULT", + "columns": [{"key": "testCase", "label": "Test case"}, {"key": "result", "label": "Result"}], + "rows": [{"key": "case-1", "cells": { + "testCase": {"value": "%s", "target": {"entityType": "TEST_CASE", "entityId": "%s"}}, + "result": "Uploaded"}}]} + """.formatted(testCaseDisplayId, testCaseDisplayId); + } + + // ------------------------------------------------------------------ calls and reading + + private JsonNode libraryCase() throws Exception { + return send(get(TEST_CASES + "/" + testCaseId), null, HttpStatus.OK); + } + + private long count(String sql, Object... args) { + return jdbcTemplate.queryForObject(sql, Long.class, args); + } + + 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 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; + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java new file mode 100644 index 0000000000..7e49d5958e --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java @@ -0,0 +1,147 @@ +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.anyString; +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.AttributeType; +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.PipelineStageReportedHandler.StageReport; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +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.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +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; + +/** + * How an iteration the agent reports is tied to the fix round it answers, and what happens when it fails or the + * agent never answers. + */ +@ExtendWith(MockitoExtension.class) +class FixRoundIterationHooksTest { + + @Mock + private TmsTestCaseFixRoundService fixRoundService; + @Mock + private TmsTestCaseFixPushRepository fixPushRepository; + @Mock + private TmsTestCaseRepository testCaseRepository; + + @InjectMocks + private FixRoundOnStageFailed onStageFailed; + @InjectMocks + private FixRoundIterationContributor contributor; + + private static PipelineIteration iterationWithFixPush(String pushId) { + var iteration = new PipelineIteration(); + iteration.setAttributes(new PipelineAttributes(List.of( + PipelineAttribute.builder().key("fixPushId").value(pushId).type(AttributeType.NONE).build()))); + return iteration; + } + + private static PipelineStage stage(PipelineIteration iteration, StatusCategory category, boolean affectsIteration) { + var stage = new PipelineStage(); + stage.setName("Fix"); + stage.setIteration(iteration); + stage.setStatusCategory(category); + stage.setAffectsIterationStatus(affectsIteration); + return stage; + } + + @Test + void onStageReported_WhenAStageOfAFixIterationFails_ShouldEndTheRoundAsFailed() { + var stage = stage(iterationWithFixPush("7"), StatusCategory.FAILED, true); + + onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, false)); + + verify(fixRoundService).failRound(7L, "Stage 'Fix' failed"); + } + + @Test + void onStageReported_WhenAStageThatDoesNotCountFails_ShouldLeaveTheRoundAlone() { + var stage = stage(iterationWithFixPush("7"), StatusCategory.FAILED, false); + + onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, false)); + + verify(fixRoundService, never()).failRound(org.mockito.ArgumentMatchers.anyLong(), anyString()); + } + + @Test + void onStageReported_WhenTheIterationAnswersNoRound_ShouldDoNothing() { + var stage = stage(new PipelineIteration(), StatusCategory.FAILED, true); + + onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, false)); + + verify(fixRoundService, never()).failRound(org.mockito.ArgumentMatchers.anyLong(), anyString()); + } + + @Test + void onStageReported_WhenTheStageDidNotFail_ShouldDoNothing() { + var stage = stage(iterationWithFixPush("7"), StatusCategory.PASSED, true); + + onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, true)); + + verify(fixRoundService, never()).failRound(org.mockito.ArgumentMatchers.anyLong(), anyString()); + } + + @Test + void iterationAttributes_OfAFixIteration_ShouldLabelItWithTheCaseAndTheRound() { + var push = new TmsTestCaseFixPush(); + push.setTestCaseId(103L); + push.setRoundNumber(1); + when(fixPushRepository.findById(7L)).thenReturn(Optional.of(push)); + var testCase = new TmsTestCase(); + testCase.setDisplayId("TC103"); + when(testCaseRepository.findById(103L)).thenReturn(Optional.of(testCase)); + + var attributes = contributor.iterationAttributes(iterationWithFixPush("7"), List.of()); + + assertEquals(1, attributes.size()); + assertEquals("fixRound", attributes.get(0).getKey()); + assertEquals("TC103 · round 1", attributes.get(0).getValue()); + assertEquals(AttributeType.META, attributes.get(0).getType()); + } + + @Test + void iterationAttributes_OfAPlainIteration_ShouldAddNothing() { + assertTrue(contributor.iterationAttributes(new PipelineIteration(), List.of()).isEmpty()); + } + + @Test + void iterationAttributes_WithANonNumericPushId_ShouldAddNothing() { + assertTrue(contributor.iterationAttributes(iterationWithFixPush("seven"), List.of()).isEmpty()); + } + + @Test + void timeoutJob_ShouldEndTheRoundsOlderThanTheTimeoutAndLeaveTheRest() { + var job = new TmsFixPushTimeoutJob(fixPushRepository, fixRoundService, Duration.ofMinutes(30)); + var now = Instant.parse("2026-10-20T10:30:00Z"); + var overdue = new TmsTestCaseFixPush(); + overdue.setId(7L); + overdue.setTestCaseId(103L); + when(fixPushRepository.findByStatusAndRequestedAtBefore( + com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus.PENDING, + Instant.parse("2026-10-20T10:00:00Z"))).thenReturn(List.of(overdue)); + + var ended = job.failUnansweredRounds(now); + + assertEquals(1, ended); + verify(fixRoundService).failRound(7L, "The agent did not answer in 30 minutes"); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java new file mode 100644 index 0000000000..ee96517cb6 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java @@ -0,0 +1,264 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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.TmsManualScenarioRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseFixedVersionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; +import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; +import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseQualityService; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; +import com.epam.reportportal.base.core.tms.service.TmsTestCaseVersionService; +import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +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.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +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 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.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +@ExtendWith(MockitoExtension.class) +class TmsTestCaseFixRoundServiceTest { + + private static final long PROJECT_ID = 1L; + private static final Long TEST_CASE_ID = 103L; + private static final Long PUSH_ID = 7L; + private static final Long PREVIOUS_ITERATION_ID = 41L; + + @Mock + private TmsTestCaseRepository testCaseRepository; + @Mock + private TmsTestCaseFixPushRepository fixPushRepository; + @Mock + private TmsTestCaseReviewCommentRepository commentRepository; + @Mock + private PipelineIterationRepository iterationRepository; + @Mock + private TmsTestCaseVersionService versionService; + @Mock + private TmsTestCaseQualityService qualityService; + @Mock + private TmsTestCaseService testCaseService; + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private TmsTestCaseFixRoundService sut; + + private TmsTestCase testCase; + private TmsTestCaseFixPush push; + private TmsTestCaseVersion newVersion; + private TmsTestCaseReviewComment applied; + private TmsTestCaseReviewComment notApplied; + private TmsTestCaseReviewComment unanswered; + + @BeforeEach + void setUp() { + testCase = new TmsTestCase(); + testCase.setId(TEST_CASE_ID); + testCase.setName("Old title"); + testCase.setStatus(TmsTestCaseStatus.READY); + + push = new TmsTestCaseFixPush(); + push.setId(PUSH_ID); + push.setProjectId(PROJECT_ID); + push.setTestCaseId(TEST_CASE_ID); + push.setRoundNumber(1); + push.setStatus(TmsFixPushStatus.PENDING); + push.setPreviousIterationId(PREVIOUS_ITERATION_ID); + + newVersion = new TmsTestCaseVersion(); + applied = sentComment(11L); + notApplied = sentComment(12L); + unanswered = sentComment(13L); + } + + private static TmsTestCaseReviewComment sentComment(Long id) { + var comment = new TmsTestCaseReviewComment(); + comment.setId(id); + comment.setTestCaseId(TEST_CASE_ID); + comment.setState(TmsReviewCommentState.SENT); + comment.setFixPushId(PUSH_ID); + return comment; + } + + private TmsTestCaseFixedVersionRQ.CommentResult result(Long commentId, boolean addressed, String reason) { + return TmsTestCaseFixedVersionRQ.CommentResult.builder().commentId(commentId).addressed(addressed).reason(reason).build(); + } + + private TmsTestCaseFixedVersionRQ fixedVersion(List scores) { + return TmsTestCaseFixedVersionRQ.builder() + .fixPushId(PUSH_ID) + .pipelineIterationId(52L) + .manualScenario(org.mockito.Mockito.mock(TmsManualScenarioRQ.class)) + .name("New title") + .qualityScores(scores) + .commentResults(List.of(result(11L, true, null), result(12L, false, "Needs a new case"))) + .build(); + } + + private void givenAPendingRoundOfAnExistingCase() { + when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + when(testCaseRepository.findByProjectIdAndId(PROJECT_ID, TEST_CASE_ID)).thenReturn(Optional.of(testCase)); + when(versionService.createNewDefaultVersion(eq(PROJECT_ID), eq(testCase), any(), eq(52L))).thenReturn(newVersion); + when(commentRepository.findByFixPushId(PUSH_ID)).thenReturn(List.of(applied, notApplied, unanswered)); + when(testCaseService.getById(PROJECT_ID, TEST_CASE_ID)).thenReturn(new TmsTestCaseRS()); + } + + private void givenAutoReady(int threshold) { + var pipeline = new Pipeline(); + pipeline.setSettings(new PipelineSettings(Map.of("autoReady", Map.of("enabled", true, "threshold", threshold)))); + var iteration = new PipelineIteration(); + iteration.setPipeline(pipeline); + when(iterationRepository.findById(PREVIOUS_ITERATION_ID)).thenReturn(Optional.of(iteration)); + } + + @Test + void applyFixedVersion_ShouldCreateTheNewVersionGradeItAndSendTheCaseBackToDraft() { + givenAPendingRoundOfAnExistingCase(); + var scores = List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()); + var rq = fixedVersion(scores); + + sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, rq); + + verify(versionService).createNewDefaultVersion(PROJECT_ID, testCase, rq.getManualScenario(), 52L); + verify(qualityService).applyQualityScores(PROJECT_ID, newVersion, scores); + verify(qualityService).applyGenerationMetadata(newVersion, rq.getGeneration()); + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); + assertTrue(testCase.isModifiedByAgent()); + assertEquals("New title", testCase.getName()); + assertEquals(TmsFixPushStatus.FIXED, push.getStatus()); + assertEquals(52L, push.getResultIterationId()); + assertNotNull(push.getFinishedAt()); + } + + @Test + void applyFixedVersion_ShouldMarkEachCommentWithItsAnswerAndTheRound() { + givenAPendingRoundOfAnExistingCase(); + + sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, + fixedVersion(List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()))); + + assertEquals(TmsReviewCommentState.ADDRESSED, applied.getState()); + assertEquals(1, applied.getRoundNumber()); + assertNull(applied.getAgentReason()); + assertEquals(TmsReviewCommentState.NOT_ADDRESSED, notApplied.getState()); + assertEquals("Needs a new case", notApplied.getAgentReason()); + assertEquals(TmsReviewCommentState.NOT_ADDRESSED, unanswered.getState()); + assertEquals(TmsTestCaseFixRoundService.UNANSWERED_REASON, unanswered.getAgentReason()); + } + + @Test + void applyFixedVersion_WithAGrade_ShouldRunAutoReadyWithTheThresholdOfThePipeline() { + givenAPendingRoundOfAnExistingCase(); + givenAutoReady(90); + + sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, + fixedVersion(List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()))); + + verify(testCaseService).applyAutoReady(PROJECT_ID, TEST_CASE_ID, 90); + } + + @Test + void applyFixedVersion_WithoutAGrade_ShouldEndAsFixedGradeFailedAndNotRunAutoReady() { + givenAPendingRoundOfAnExistingCase(); + + sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null)); + + assertEquals(TmsFixPushStatus.FIXED_GRADE_FAILED, push.getStatus()); + assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); + verify(testCaseService, never()).applyAutoReady(anyLong(), anyLong(), anyInt()); + } + + @Test + void applyFixedVersion_ShouldTellTheReviewOfTheIterationsThatTheLifecycleChanged() { + givenAPendingRoundOfAnExistingCase(); + + sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null)); + + var event = ArgumentCaptor.forClass(Object.class); + verify(eventPublisher).publishEvent(event.capture()); + assertEquals(new TmsTestCasesLifecycleChangedEvent(List.of(TEST_CASE_ID)), event.getValue()); + } + + @Test + void applyFixedVersion_WhenTheRoundHasAlreadyEnded_ShouldBeAConflictAndChangeNothing() { + push.setStatus(TmsFixPushStatus.FAILED); + when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null))); + + assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); + verify(versionService, never()).createNewDefaultVersion(anyLong(), any(), any(), any()); + } + + @Test + void applyFixedVersion_ForARoundOfAnotherTestCase_ShouldBeNotFound() { + push.setTestCaseId(999L); + when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null))); + + assertEquals(ErrorType.NOT_FOUND.getCode(), ex.getErrorType().getCode()); + } + + @Test + void failRound_ShouldReturnTheCommentsToNotSentAndEndTheRoundAsFailed() { + when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + when(commentRepository.findByFixPushId(PUSH_ID)).thenReturn(List.of(applied, notApplied)); + + sut.failRound(PUSH_ID, "Stage 'Fix' failed"); + + assertEquals(TmsFixPushStatus.FAILED, push.getStatus()); + assertEquals("Stage 'Fix' failed", push.getFailureReason()); + assertNotNull(push.getFinishedAt()); + assertEquals(TmsReviewCommentState.NOT_SENT, applied.getState()); + assertNull(applied.getFixPushId()); + assertEquals(TmsReviewCommentState.NOT_SENT, notApplied.getState()); + } + + @Test + void failRound_WhenTheRoundAlreadyEnded_ShouldDoNothing() { + push.setStatus(TmsFixPushStatus.FIXED); + when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + + sut.failRound(PUSH_ID, "late"); + + assertEquals(TmsFixPushStatus.FIXED, push.getStatus()); + verify(commentRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java new file mode 100644 index 0000000000..6eed897678 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java @@ -0,0 +1,284 @@ +package com.epam.reportportal.base.core.tms.pipeline; + +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 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.CiRunRequest; +import com.epam.reportportal.base.core.aifactory.connector.CiTriggerConnector; +import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; +import com.epam.reportportal.base.infrastructure.persistence.dao.IntegrationRepository; +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.dao.tms.PipelineStageTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +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.enums.IntegrationGroupEnum; +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.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; +import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; + +@ExtendWith(MockitoExtension.class) +class TmsTestCasePushToAgentServiceTest { + + private static final long PROJECT_ID = 1L; + private static final Long TEST_CASE_ID = 103L; + private static final Long USER_ID = 5L; + private static final Long ITERATION_ID = 41L; + + @Mock + private TmsTestCaseRepository testCaseRepository; + @Mock + private TmsTestCaseReviewCommentRepository commentRepository; + @Mock + private TmsTestCaseFixPushRepository fixPushRepository; + @Mock + private PipelineStageTestCaseRepository stageTestCaseRepository; + @Mock + private PipelineIterationRepository iterationRepository; + @Mock + private PipelineStageRepository stageRepository; + @Mock + private IntegrationRepository integrationRepository; + @Mock + private PlatformTransactionManager transactionManager; + @Mock + private CiTriggerConnector githubConnector; + + private TmsTestCasePushToAgentService sut; + + private TmsTestCase aiCase; + private TmsTestCaseReviewComment unsent; + private Integration integration; + + @BeforeEach + void setUp() { + lenient().when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class)); + when(githubConnector.getSupportedExecutorType()).thenReturn("GITHUB_ACTIONS"); + sut = new TmsTestCasePushToAgentService(testCaseRepository, commentRepository, fixPushRepository, + stageTestCaseRepository, iterationRepository, stageRepository, integrationRepository, + List.of(githubConnector), transactionManager); + + aiCase = new TmsTestCase(); + aiCase.setId(TEST_CASE_ID); + aiCase.setOrigin(TmsTestCaseOrigin.AI); + lenient().when(testCaseRepository.findByProjectIdAndId(PROJECT_ID, TEST_CASE_ID)).thenReturn(Optional.of(aiCase)); + + unsent = new TmsTestCaseReviewComment(); + unsent.setId(11L); + unsent.setTestCaseId(TEST_CASE_ID); + unsent.setState(TmsReviewCommentState.NOT_SENT); + } + + private void givenAnIterationWithCiAndAnIntegration() { + var project = new Project(); + project.setId(PROJECT_ID); + var pipeline = new Pipeline(); + pipeline.setProject(project); + var iteration = new PipelineIteration(); + iteration.setId(ITERATION_ID); + iteration.setPipeline(pipeline); + when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(TEST_CASE_ID))).thenReturn(List.of(30L, ITERATION_ID)); + when(iterationRepository.findById(ITERATION_ID)).thenReturn(Optional.of(iteration)); + + var stage = new PipelineStage(); + stage.setExecution(new PipelineExecutionData(PipelineExecution.builder().executorType("GITHUB_ACTIONS") + .retryParameters(new HashMap<>(Map.of("repo", "org/repo", "workflowRef", "ai-pipeline.yml"))).build())); + when(stageRepository.findByIterationIdOrderBySequenceAsc(ITERATION_ID)).thenReturn(List.of(new PipelineStage(), stage)); + + var type = new IntegrationType(); + type.setName("github-actions"); + integration = new Integration(); + integration.setEnabled(true); + integration.setType(type); + when(githubConnector.getIntegrationTypeName()).thenReturn("github-actions"); + when(integrationRepository.findAllProjectByGroup(project, IntegrationGroupEnum.AUTOMATION)).thenReturn(List.of(integration)); + } + + private void givenTheCaseHasUnsentComments() { + when(commentRepository.findByTestCaseIdAndState(TEST_CASE_ID, TmsReviewCommentState.NOT_SENT)).thenReturn(List.of(unsent)); + } + + private void givenPushIsSaved() { + when(fixPushRepository.findLastRoundNumber(TEST_CASE_ID)).thenReturn(0); + when(fixPushRepository.saveAndFlush(any(TmsTestCaseFixPush.class))).thenAnswer(call -> { + TmsTestCaseFixPush saved = call.getArgument(0); + saved.setId(7L); + return saved; + }); + } + + @Test + void push_ShouldRecordRoundOneStartTheJobWithOnlyIdsAndMarkTheCommentsAsSent() { + givenTheCaseHasUnsentComments(); + givenAnIterationWithCiAndAnIntegration(); + givenPushIsSaved(); + var saved = new TmsTestCaseFixPush(); + saved.setId(7L); + saved.setTestCaseId(TEST_CASE_ID); + saved.setRoundNumber(1); + saved.setStatus(TmsFixPushStatus.PENDING); + when(fixPushRepository.findById(7L)).thenReturn(Optional.of(saved)); + when(githubConnector.triggerRun(any(), any())).thenReturn("https://github.com/org/repo/actions/workflows/fix.yml"); + when(commentRepository.findByFixPushId(7L)).thenReturn(List.of(unsent)); + + var round = sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID); + + var request = ArgumentCaptor.forClass(CiRunRequest.class); + verify(githubConnector).triggerRun(any(), request.capture()); + assertEquals("org/repo", request.getValue().repo()); + assertEquals("ai-pipeline.yml", request.getValue().workflowRef()); + assertEquals(Map.of("test_case_id", "103", "push_id", "7", "previous_iteration_id", "41"), request.getValue().inputs()); + assertEquals(TmsReviewCommentState.SENT, unsent.getState()); + assertEquals(7L, unsent.getFixPushId()); + assertEquals(7L, round.getId()); + assertEquals(1, round.getRoundNumber()); + assertEquals("https://github.com/org/repo/actions/workflows/fix.yml", round.getWorkflowUrl()); + } + + @Test + void push_ShouldNumberTheRoundAfterTheLastOneOfTheCase() { + givenTheCaseHasUnsentComments(); + givenAnIterationWithCiAndAnIntegration(); + when(fixPushRepository.findLastRoundNumber(TEST_CASE_ID)).thenReturn(2); + when(fixPushRepository.saveAndFlush(any(TmsTestCaseFixPush.class))).thenAnswer(call -> { + TmsTestCaseFixPush saved = call.getArgument(0); + saved.setId(9L); + return saved; + }); + when(githubConnector.triggerRun(any(), any())).thenReturn("url"); + when(fixPushRepository.findById(9L)).thenAnswer(call -> Optional.of(new TmsTestCaseFixPush())); + when(commentRepository.findByFixPushId(9L)).thenReturn(List.of()); + + sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID); + + var push = ArgumentCaptor.forClass(TmsTestCaseFixPush.class); + verify(fixPushRepository).saveAndFlush(push.capture()); + assertEquals(3, push.getValue().getRoundNumber()); + assertEquals(TmsFixPushStatus.PENDING, push.getValue().getStatus()); + assertEquals(ITERATION_ID, push.getValue().getPreviousIterationId()); + assertEquals(USER_ID, push.getValue().getRequestedBy()); + } + + @Test + void push_WhenTheJobCannotBeStarted_ShouldRecordNoRoundAndKeepTheCommentsNotSent() { + givenTheCaseHasUnsentComments(); + givenAnIterationWithCiAndAnIntegration(); + givenPushIsSaved(); + when(githubConnector.triggerRun(any(), any())).thenThrow(new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "CI is down")); + when(commentRepository.findByFixPushId(7L)).thenReturn(List.of(unsent)); + unsent.setState(TmsReviewCommentState.SENT); + unsent.setFixPushId(7L); + + assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertEquals(TmsReviewCommentState.NOT_SENT, unsent.getState()); + assertNull(unsent.getFixPushId()); + verify(fixPushRepository).deleteById(7L); + } + + @Test + void push_WithoutUnsentComments_ShouldBeRejectedBeforeTouchingCi() { + when(commentRepository.findByTestCaseIdAndState(TEST_CASE_ID, TmsReviewCommentState.NOT_SENT)).thenReturn(List.of()); + + var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + verify(githubConnector, never()).triggerRun(any(), any()); + } + + @Test + void push_OfAManualCase_ShouldBeRejected() { + aiCase.setOrigin(TmsTestCaseOrigin.MANUAL); + + var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + } + + @Test + void push_WhileARoundIsRunning_ShouldBeAConflict() { + when(fixPushRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(true); + + var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + + @Test + void push_OfACaseNoIterationProduced_ShouldBeRejected() { + givenTheCaseHasUnsentComments(); + when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(TEST_CASE_ID))).thenReturn(List.of()); + + var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + } + + @Test + void push_WhenNoStageTellsWhereTheIterationRan_ShouldBeRejectedAsNotConnectedToCi() { + givenTheCaseHasUnsentComments(); + when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(TEST_CASE_ID))).thenReturn(List.of(ITERATION_ID)); + when(stageRepository.findByIterationIdOrderBySequenceAsc(ITERATION_ID)).thenReturn(List.of(new PipelineStage())); + + var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertTrue(ex.getMessage().contains("not connected to CI")); + } + + @Test + void push_WithoutAnEnabledIntegration_ShouldBeRejected() { + givenTheCaseHasUnsentComments(); + givenAnIterationWithCiAndAnIntegration(); + integration.setEnabled(false); + + var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + verify(fixPushRepository, never()).saveAndFlush(any()); + } + + @Test + void getPush_OfAnotherTestCase_ShouldBeNotFound() { + var push = new TmsTestCaseFixPush(); + push.setProjectId(PROJECT_ID); + push.setTestCaseId(999L); + when(fixPushRepository.findById(7L)).thenReturn(Optional.of(push)); + + var ex = assertThrows(ReportPortalException.class, () -> sut.getPush(PROJECT_ID, TEST_CASE_ID, 7L)); + + assertEquals(ErrorType.NOT_FOUND.getCode(), ex.getErrorType().getCode()); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java new file mode 100644 index 0000000000..8733efc285 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java @@ -0,0 +1,82 @@ +package com.epam.reportportal.base.core.tms.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +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.Set; +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 TmsTestCaseReadyEligibilityServiceTest { + + @Mock + private TmsTestCaseReviewCommentRepository commentRepository; + @Mock + private TmsTestCaseFixPushRepository fixPushRepository; + + @InjectMocks + private TmsTestCaseReadyEligibilityService sut; + + @Test + void findCasesThatCannotBecomeReady_ShouldContainCasesWithUnsentCommentsAndCasesTheAgentIsFixing() { + var ids = List.of(1L, 2L, 3L); + when(commentRepository.findTestCaseIdsWithState(ids, TmsReviewCommentState.NOT_SENT)).thenReturn(List.of(1L)); + when(fixPushRepository.findTestCaseIdsWithStatus(ids, TmsFixPushStatus.PENDING)).thenReturn(List.of(2L)); + + assertEquals(Set.of(1L, 2L), sut.findCasesThatCannotBecomeReady(ids)); + } + + @Test + void findCasesThatCannotBecomeReady_WithoutCases_ShouldBeEmptyWithoutAskingTheDatabase() { + assertTrue(sut.findCasesThatCannotBecomeReady(List.of()).isEmpty()); + } + + @Test + void canBecomeReady_WhenNothingBlocksTheCase_ShouldBeTrue() { + lenient().when(commentRepository.findTestCaseIdsWithState(List.of(1L), TmsReviewCommentState.NOT_SENT)) + .thenReturn(List.of()); + lenient().when(fixPushRepository.findTestCaseIdsWithStatus(List.of(1L), TmsFixPushStatus.PENDING)) + .thenReturn(List.of()); + + assertTrue(sut.canBecomeReady(1L)); + } + + @Test + void assertCanBecomeReady_WhileTheAgentIsFixing_ShouldBeAConflict() { + when(fixPushRepository.existsByTestCaseIdAndStatus(1L, TmsFixPushStatus.PENDING)).thenReturn(true); + + var ex = assertThrows(ReportPortalException.class, () -> sut.assertCanBecomeReady(1L)); + + assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + + @Test + void assertCanBecomeReady_WithUnsentComments_ShouldBeAConflict() { + when(commentRepository.countByTestCaseIdAndState(1L, TmsReviewCommentState.NOT_SENT)).thenReturn(1L); + + var ex = assertThrows(ReportPortalException.class, () -> sut.assertCanBecomeReady(1L)); + + assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + + @Test + void assertCanBecomeReady_WithNoCommentsAndNoRound_ShouldPass() { + assertDoesNotThrow(() -> sut.assertCanBecomeReady(1L)); + } +} diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java new file mode 100644 index 0000000000..d00aded4b2 --- /dev/null +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java @@ -0,0 +1,232 @@ +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.TmsReviewCommentRQ; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; +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.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 TmsTestCaseReviewCommentServiceImplTest { + + private static final long PROJECT_ID = 1L; + private static final Long TEST_CASE_ID = 103L; + private static final Long USER_ID = 5L; + + @Mock + private TmsTestCaseRepository testCaseRepository; + @Mock + private TmsTestCaseReviewCommentRepository commentRepository; + @Mock + private TmsTestCaseFixPushRepository fixPushRepository; + + @InjectMocks + private TmsTestCaseReviewCommentServiceImpl sut; + + private TmsTestCase aiCase; + + @BeforeEach + void setUp() { + aiCase = new TmsTestCase(); + aiCase.setId(TEST_CASE_ID); + aiCase.setOrigin(TmsTestCaseOrigin.AI); + when(testCaseRepository.findByProjectIdAndId(PROJECT_ID, TEST_CASE_ID)).thenReturn(Optional.of(aiCase)); + } + + private static TmsReviewCommentRQ stepComment(int stepNumber, String text) { + return TmsReviewCommentRQ.builder().targetType(TmsReviewCommentTarget.STEP).stepNumber(stepNumber).text(text).build(); + } + + private static TmsTestCaseReviewComment comment(Long id, Long authorId, TmsReviewCommentState state) { + var comment = new TmsTestCaseReviewComment(); + comment.setId(id); + comment.setTestCaseId(TEST_CASE_ID); + comment.setTargetType(TmsReviewCommentTarget.STEP); + comment.setStepNumber(2); + comment.setCommentText("Name the exact toast text"); + comment.setAuthorId(authorId); + comment.setCreatedAt(Instant.parse("2026-10-20T10:00:00Z")); + comment.setState(state); + return comment; + } + + @Test + void addComment_OnAnAiCase_ShouldStoreItAsNotSentWithTheAuthor() { + when(commentRepository.save(any(TmsTestCaseReviewComment.class))).thenAnswer(call -> call.getArgument(0)); + + var created = sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, stepComment(2, " Name the exact toast text ")); + + assertEquals(TmsReviewCommentState.NOT_SENT, created.getState()); + assertEquals(TmsReviewCommentTarget.STEP, created.getTargetType()); + assertEquals(2, created.getStepNumber()); + assertEquals("Name the exact toast text", created.getText()); + assertEquals(USER_ID, created.getAuthorId()); + } + + @Test + void addComment_OnAPrecondition_ShouldNotKeepAStepNumber() { + when(commentRepository.save(any(TmsTestCaseReviewComment.class))).thenAnswer(call -> call.getArgument(0)); + var rq = TmsReviewCommentRQ.builder().targetType(TmsReviewCommentTarget.PRECONDITION).text("Split this case").build(); + + var created = sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, rq); + + assertNull(created.getStepNumber()); + } + + @Test + void addComment_OnAManualCase_ShouldBeRejected() { + aiCase.setOrigin(TmsTestCaseOrigin.MANUAL); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, stepComment(1, "text"))); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + verify(commentRepository, never()).save(any()); + } + + @Test + void addComment_WhileTheAgentIsFixingTheCase_ShouldBeRejectedAsAConflict() { + when(fixPushRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(true); + + var ex = assertThrows(ReportPortalException.class, + () -> sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, stepComment(1, "text"))); + + assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + + @Test + void addComment_OnAStepWithoutItsNumber_ShouldBeRejected() { + var rq = TmsReviewCommentRQ.builder().targetType(TmsReviewCommentTarget.STEP).text("text").build(); + + var ex = assertThrows(ReportPortalException.class, () -> sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, rq)); + + assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); + } + + @Test + void addComment_OnAPreconditionWithAStepNumber_ShouldBeRejected() { + var rq = TmsReviewCommentRQ.builder().targetType(TmsReviewCommentTarget.PRECONDITION).stepNumber(1).text("text").build(); + + assertThrows(ReportPortalException.class, () -> sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, rq)); + } + + @Test + void getReview_ShouldCountTheUnsentCommentsAndTellWhetherTheAgentIsFixing() { + when(commentRepository.findByTestCaseIdOrderByCreatedAtAscIdAsc(TEST_CASE_ID)).thenReturn(List.of( + comment(1L, USER_ID, TmsReviewCommentState.NOT_SENT), + comment(2L, USER_ID, TmsReviewCommentState.NOT_SENT), + comment(3L, USER_ID, TmsReviewCommentState.ADDRESSED))); + var push = new TmsTestCaseFixPush(); + push.setRoundNumber(2); + when(fixPushRepository.findByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)) + .thenReturn(Optional.of(push)); + + var review = sut.getReview(PROJECT_ID, TEST_CASE_ID); + + assertEquals(3, review.getComments().size()); + assertEquals(2, review.getUnsentCount()); + assertTrue(review.isFixInProgress()); + assertEquals(2, review.getFixRound()); + } + + @Test + void getReview_WhenNothingIsRunning_ShouldNotReportAFixRound() { + when(commentRepository.findByTestCaseIdOrderByCreatedAtAscIdAsc(TEST_CASE_ID)).thenReturn(List.of()); + when(fixPushRepository.findByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(Optional.empty()); + + var review = sut.getReview(PROJECT_ID, TEST_CASE_ID); + + assertFalse(review.isFixInProgress()); + assertNull(review.getFixRound()); + assertEquals(0, review.getUnsentCount()); + } + + @Test + void deleteOwnComment_ShouldRemoveACommentOfTheUserThatIsNotSentYet() { + var own = comment(1L, USER_ID, TmsReviewCommentState.NOT_SENT); + when(commentRepository.findById(1L)).thenReturn(Optional.of(own)); + + sut.deleteOwnComment(PROJECT_ID, TEST_CASE_ID, 1L, USER_ID); + + verify(commentRepository).delete(own); + } + + @Test + void deleteOwnComment_OfSomeoneElse_ShouldBeForbidden() { + when(commentRepository.findById(1L)).thenReturn(Optional.of(comment(1L, 99L, TmsReviewCommentState.NOT_SENT))); + + var ex = assertThrows(ReportPortalException.class, () -> sut.deleteOwnComment(PROJECT_ID, TEST_CASE_ID, 1L, USER_ID)); + + assertEquals(ErrorType.FORBIDDEN_OPERATION.getCode(), ex.getErrorType().getCode()); + verify(commentRepository, never()).delete(any()); + } + + @Test + void deleteOwnComment_ThatWasPushedToTheAgent_ShouldBeRejected() { + when(commentRepository.findById(1L)).thenReturn(Optional.of(comment(1L, USER_ID, TmsReviewCommentState.SENT))); + + var ex = assertThrows(ReportPortalException.class, () -> sut.deleteOwnComment(PROJECT_ID, TEST_CASE_ID, 1L, USER_ID)); + + assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); + } + + @Test + void deleteOwnComment_OfAnotherTestCase_ShouldBeNotFound() { + var other = comment(1L, USER_ID, TmsReviewCommentState.NOT_SENT); + other.setTestCaseId(999L); + when(commentRepository.findById(1L)).thenReturn(Optional.of(other)); + + var ex = assertThrows(ReportPortalException.class, () -> sut.deleteOwnComment(PROJECT_ID, TEST_CASE_ID, 1L, USER_ID)); + + assertEquals(ErrorType.NOT_FOUND.getCode(), ex.getErrorType().getCode()); + } + + @Test + void discardUnsentComments_ShouldRemoveOnlyTheNotSentOnes() { + when(commentRepository.deleteByTestCaseIdAndState(TEST_CASE_ID, TmsReviewCommentState.NOT_SENT)).thenReturn(2); + + var removed = sut.discardUnsentComments(PROJECT_ID, TEST_CASE_ID); + + assertEquals(2, removed); + var state = ArgumentCaptor.forClass(TmsReviewCommentState.class); + verify(commentRepository).deleteByTestCaseIdAndState(any(), state.capture()); + assertEquals(TmsReviewCommentState.NOT_SENT, state.getValue()); + } + + @Test + void discardUnsentComments_WhileTheAgentIsFixingTheCase_ShouldBeRejected() { + when(fixPushRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(true); + + assertThrows(ReportPortalException.class, () -> sut.discardUnsentComments(PROJECT_ID, TEST_CASE_ID)); + + verify(commentRepository, never()).deleteByTestCaseIdAndState(any(), any()); + } +} 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 7b5cad4bae..35170c6c61 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 @@ -148,6 +148,9 @@ class TmsTestCaseServiceImplTest { @Mock private TmsTestCaseQualityService tmsTestCaseQualityService; + @Mock + private TmsTestCaseReadyEligibilityService tmsTestCaseReadyEligibilityService; + @Mock private HttpServletResponse response; @@ -189,6 +192,8 @@ class TmsTestCaseServiceImplTest { @BeforeEach void setUp() { + // nothing blocks a case from becoming Ready unless a test says so + lenient().when(tmsTestCaseReadyEligibilityService.canBecomeReady(any())).thenReturn(true); projectId = 1L; testCaseId = 2L; testFolderId = 4L; diff --git a/src/test/resources/db/tms/fixround/fix-round-cleanup.sql b/src/test/resources/db/tms/fixround/fix-round-cleanup.sql new file mode 100644 index 0000000000..7e8f6e08cb --- /dev/null +++ b/src/test/resources/db/tms/fixround/fix-round-cleanup.sql @@ -0,0 +1,9 @@ +-- The fix round test commits (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; deleting the +-- test cases cascades to their versions, scores, review comments and fix rounds. +delete from pipeline where project_id = 1 and name = 'Test case generation'; +delete from tms_test_case where test_folder_id = 920; +delete from tms_test_folder where id = 920; +delete from tms_quality_standard where id = 920; +delete from integration where id = 920; +delete from tms_project_sequence where project_id = 1 and entity_type = 'TEST_CASE'; diff --git a/src/test/resources/db/tms/fixround/fix-round-fill.sql b/src/test/resources/db/tms/fixround/fix-round-fill.sql new file mode 100644 index 0000000000..9c42a6cd62 --- /dev/null +++ b/src/test/resources/db/tms/fixround/fix-round-fill.sql @@ -0,0 +1,24 @@ +-- Fixture of TestCaseFixRoundE2eIntegrationTest (project 1 = superadmin_personal): the Library folder the test +-- cases live in, the quality standard their grades are reported against and the GitHub Actions integration the +-- fix job is started with. 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 (920, 'fix-round', 'tms/library/fixround', 1); + +insert into tms_quality_standard (id, project_id, name, description) +values (920, 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 (920, 920, 'Atomicity', 15, 1, 'atomicity'), + (921, 920, 'Clear steps', 20, 2, 'clear_steps'), + (922, 920, 'Clear expected results', 20, 3, 'clear_expected_results'), + (923, 920, 'No invented logic', 20, 4, 'no_invented_logic'), + (924, 920, 'No invented UI', 15, 5, 'no_invented_ui'), + (925, 920, '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)); + +insert into integration (id, project_id, type, enabled, creator, creation_date, params, name) +values (920, 1, 9000, true, 'superadmin', now(), + '{"params": {"token": "gh-test-token", "ref": "main", "repo": "org/repo", "fixWorkflowRef": "fix-test-case.yml"}}', + 'fix-round-github-actions'); From 54ec0d85e33fe6ab80dfe422a8a0c8bb11bdc2f7 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Fri, 23 Oct 2026 21:10:54 +0200 Subject: [PATCH 10/12] Pipelines implementation --- project-properties.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/project-properties.gradle b/project-properties.gradle index f19eaafdbb..2a3f8639e4 100755 --- a/project-properties.gradle +++ b/project-properties.gradle @@ -117,7 +117,8 @@ project.ext { (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_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' + (migrationsUrl + '/migrations/230_pipeline_and_tms_quality.up.sql') : 'V230__pipeline_and_tms_quality.up.sql', + (migrationsUrl + '/migrations/231_tms_fix_rounds.up.sql') : 'V231__tms_fix_rounds.up.sql' ] excludeTests = ['**/entity/**', '**/aop/**', From 8d3badea470e30785057e34497cb931012c7d9da Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sat, 24 Oct 2026 10:50:07 +0200 Subject: [PATCH 11/12] Pipelines implementation --- .../aifactory/connector/CiRunRequest.java | 2 +- .../connector/CiTriggerConnector.java | 2 +- .../controller/TestCaseReviewController.java | 48 +++---- .../{TmsFixPushRS.java => TmsFixRoundRS.java} | 6 +- ...ersionRQ.java => TmsFixRoundResultRQ.java} | 11 +- .../base/core/tms/dto/TmsReviewCommentRS.java | 4 +- .../tms/mapper/TmsReviewCommentMapper.java | 2 +- .../FixRoundIterationContributor.java | 18 +-- .../pipeline/FixRoundIterationSupport.java | 10 +- .../tms/pipeline/FixRoundOnStageFailed.java | 6 +- ...outJob.java => TmsFixRoundTimeoutJob.java} | 30 ++--- .../pipeline/TmsTestCaseFixRoundService.java | 74 +++++------ ...a => TmsTestCaseFixRoundStartService.java} | 118 ++++++++--------- .../TmsTestCaseReadyEligibilityService.java | 10 +- .../TmsTestCaseReviewCommentServiceImpl.java | 14 +- ...ava => TmsTestCaseFixRoundRepository.java} | 24 ++-- .../TmsTestCaseReviewCommentRepository.java | 2 +- ...eFixPush.java => TmsTestCaseFixRound.java} | 8 +- .../entity/tms/TmsTestCaseReviewComment.java | 6 +- ...PushStatus.java => TmsFixRoundStatus.java} | 4 +- .../connector/CiConnectorTriggerRunTest.java | 6 +- .../TestCaseFixRoundE2eIntegrationTest.java | 62 ++++----- .../pipeline/FixRoundIterationHooksTest.java | 38 +++--- .../TmsTestCaseFixRoundServiceTest.java | 115 +++++++++-------- ... TmsTestCaseFixRoundStartServiceTest.java} | 120 +++++++++--------- ...msTestCaseReadyEligibilityServiceTest.java | 12 +- ...sTestCaseReviewCommentServiceImplTest.java | 18 +-- 27 files changed, 383 insertions(+), 387 deletions(-) rename src/main/java/com/epam/reportportal/base/core/tms/dto/{TmsFixPushRS.java => TmsFixRoundRS.java} (92%) rename src/main/java/com/epam/reportportal/base/core/tms/dto/{TmsTestCaseFixedVersionRQ.java => TmsFixRoundResultRQ.java} (84%) rename src/main/java/com/epam/reportportal/base/core/tms/pipeline/{TmsFixPushTimeoutJob.java => TmsFixRoundTimeoutJob.java} (60%) rename src/main/java/com/epam/reportportal/base/core/tms/pipeline/{TmsTestCasePushToAgentService.java => TmsTestCaseFixRoundStartService.java} (75%) rename src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/{TmsTestCaseFixPushRepository.java => TmsTestCaseFixRoundRepository.java} (56%) rename src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/{TmsTestCaseFixPush.java => TmsTestCaseFixRound.java} (93%) rename src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/{TmsFixPushStatus.java => TmsFixRoundStatus.java} (72%) rename src/test/java/com/epam/reportportal/base/core/tms/pipeline/{TmsTestCasePushToAgentServiceTest.java => TmsTestCaseFixRoundStartServiceTest.java} (72%) diff --git a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java index 6714afa132..73fad1b613 100644 --- a/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java +++ b/src/main/java/com/epam/reportportal/base/core/aifactory/connector/CiRunRequest.java @@ -6,7 +6,7 @@ * What it takes to start a brand-new run in a CI system, as opposed to running a stage again. *

* Example: {@code new CiRunRequest("org/repo", "fix-test-case.yml", Map.of("test_case_id", "103", - * "push_id", "7"))} starts the workflow {@code fix-test-case.yml} of the repository {@code org/repo} with + * "fix_round_id", "7"))} starts the workflow {@code fix-test-case.yml} of the repository {@code org/repo} with * the two inputs. For GitLab CI {@code repo} is the project id, {@code workflowRef} the git ref, and the inputs * become pipeline variables. * 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 index b3ff94e5e9..0b405f8342 100644 --- 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 @@ -40,7 +40,7 @@ public interface CiTriggerConnector { * Starts a new run, not tied to any stage, e.g. the job that fixes a test case after a review. *

* Example: the fix job of GitHub Actions is the workflow {@code fix-test-case.yml} started with the inputs - * {@code test_case_id} and {@code push_id}. An integration may name that workflow itself in its parameter + * {@code test_case_id} and {@code fix_round_id}. An integration may name that workflow itself in its parameter * {@code fixWorkflowRef}, which then wins over the one in the request. * * @param integration credentials/config for the system (token, base URL) diff --git a/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java b/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java index a5a4f844af..160f5134fa 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/controller/TestCaseReviewController.java @@ -3,14 +3,14 @@ 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.TmsFixPushRS; +import com.epam.reportportal.base.core.tms.dto.TmsFixRoundRS; import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRQ; import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRS; -import com.epam.reportportal.base.core.tms.dto.TmsTestCaseFixedVersionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsFixRoundResultRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseReviewRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.pipeline.TmsTestCaseFixRoundService; -import com.epam.reportportal.base.core.tms.pipeline.TmsTestCasePushToAgentService; +import com.epam.reportportal.base.core.tms.pipeline.TmsTestCaseFixRoundStartService; import com.epam.reportportal.base.core.tms.service.TmsTestCaseReviewCommentService; import com.epam.reportportal.base.infrastructure.persistence.commons.EntityUtils; import com.epam.reportportal.base.infrastructure.persistence.commons.ReportPortalUser; @@ -27,17 +27,18 @@ 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; /** - * Review of AI test cases: comments of an editor, "Push to agent", and the fixed version the agent sends back. + * Review of AI test cases: comments of an editor, "Push to agent" (a fix round), and the result the agent sends back. *

* Example: the editor posts a comment to {@code .../test-case/103/review/comment}, then - * {@code POST .../test-case/103/review/push}; the job reads {@code GET .../review/push/7}, fixes the case and - * answers with {@code POST .../test-case/103/fixed-version}. + * {@code POST .../test-case/103/review/fix-round}; the job reads {@code GET .../review/fix-round/7}, fixes the case and + * answers with {@code PUT .../test-case/103/review/fix-round/7/result}. */ @RestController @RequestMapping("/v1/project/{projectKey}/tms/test-case/{testCaseId}") @@ -47,7 +48,7 @@ public class TestCaseReviewController { private final TmsTestCaseReviewCommentService reviewCommentService; - private final TmsTestCasePushToAgentService pushToAgentService; + private final TmsTestCaseFixRoundStartService fixRoundStartService; private final TmsTestCaseFixRoundService fixRoundService; private final ProjectExtractor projectExtractor; @@ -100,39 +101,40 @@ public TmsTestCaseReviewRS discardComments(@PathVariable("projectKey") String pr } @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) - @PostMapping("/review/push") + @PostMapping("/review/fix-round") @Operation(summary = "Push the review comments to the agent", description = "Starts the fix job of the pipeline that produced the test case with all its unsent " + "comments and records a fix round. If the job cannot be started nothing is recorded and the " + "comments stay not sent.") @ApiResponse(responseCode = "200", description = "Fix round started") - public TmsFixPushRS pushToAgent(@PathVariable("projectKey") String projectKey, + public TmsFixRoundRS startFixRound(@PathVariable("projectKey") String projectKey, @PathVariable("testCaseId") Long testCaseId, @AuthenticationPrincipal ReportPortalUser user) { - return pushToAgentService.push(projectId(user, projectKey), testCaseId, user.getUserId()); + return fixRoundStartService.start(projectId(user, projectKey), testCaseId, user.getUserId()); } @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) - @GetMapping("/review/push/{pushId}") + @GetMapping("/review/fix-round/{roundId}") @Operation(summary = "Get a fix round", description = "The fix round with the comments it carries; the fix job reads it to know what to apply.") @ApiResponse(responseCode = "200", description = "Successful operation") - public TmsFixPushRS getPush(@PathVariable("projectKey") String projectKey, - @PathVariable("testCaseId") Long testCaseId, @PathVariable("pushId") Long pushId, + public TmsFixRoundRS getFixRound(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @PathVariable("roundId") Long roundId, @AuthenticationPrincipal ReportPortalUser user) { - return pushToAgentService.getPush(projectId(user, projectKey), testCaseId, pushId); + return fixRoundStartService.getRound(projectId(user, projectKey), testCaseId, roundId); } @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) - @PostMapping("/fixed-version") - @Operation(summary = "Report the fixed version of a test case", - description = "Ends a fix round: the content becomes a new default version of the test case (the previous " - + "version stays as history), its grade is stored, the case goes back to Draft and each pushed " - + "comment is marked addressed or not. All in one call.") + @PutMapping("/review/fix-round/{roundId}/result") + @Operation(summary = "Report the result of a fix round", + description = "Ends the fix round with the fixed test case: the content becomes a new default version of " + + "the test case (the previous version stays as history), its grade is stored, the case goes back to " + + "Draft and each comment of the round is marked addressed or not. All in one call; a round that has " + + "already ended answers 409.") @ApiResponse(responseCode = "200", description = "The test case after the fix") - public TmsTestCaseRS reportFixedVersion(@PathVariable("projectKey") String projectKey, - @PathVariable("testCaseId") Long testCaseId, @RequestBody @Valid TmsTestCaseFixedVersionRQ rq, - @AuthenticationPrincipal ReportPortalUser user) { - return fixRoundService.applyFixedVersion(projectId(user, projectKey), testCaseId, rq); + public TmsTestCaseRS reportFixRoundResult(@PathVariable("projectKey") String projectKey, + @PathVariable("testCaseId") Long testCaseId, @PathVariable("roundId") Long roundId, + @RequestBody @Valid TmsFixRoundResultRQ rq, @AuthenticationPrincipal ReportPortalUser user) { + return fixRoundService.applyResult(projectId(user, projectKey), testCaseId, roundId, rq); } private long projectId(ReportPortalUser user, String projectKey) { diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixRoundRS.java similarity index 92% rename from src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java rename to src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixRoundRS.java index b4368ed92e..aa1a4761af 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixPushRS.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixRoundRS.java @@ -1,6 +1,6 @@ package com.epam.reportportal.base.core.tms.dto; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.fasterxml.jackson.annotation.JsonInclude; import java.time.Instant; import java.util.List; @@ -22,7 +22,7 @@ @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class TmsFixPushRS { +public class TmsFixRoundRS { private Long id; @@ -30,7 +30,7 @@ public class TmsFixPushRS { private Integer roundNumber; - private TmsFixPushStatus status; + private TmsFixRoundStatus status; private Long previousIterationId; diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixRoundResultRQ.java similarity index 84% rename from src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java rename to src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixRoundResultRQ.java index 03f852b608..a4259e162f 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsTestCaseFixedVersionRQ.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsFixRoundResultRQ.java @@ -11,10 +11,10 @@ import lombok.NoArgsConstructor; /** - * What the agent sends when it has fixed a test case in a fix round: the fixed content, its fresh grade and the + * The result of a fix round, sent by the agent when it has fixed the test case: the fixed content, its fresh grade and the * answer to each comment, in one call, so the case, its scores and its comments never disagree. *

- * Example: {@code {"fixPushId": 7, "pipelineIterationId": 52, "manualScenario": {...the fixed steps...}, + * Example: {@code {"pipelineIterationId": 52, "manualScenario": {...the fixed steps...}, * "qualityScores": [{"criterionKey": "atomicity", "score": 15}], "generation": {"costUsd": 0.22}, * "commentResults": [{"commentId": 11, "addressed": true}, {"commentId": 12, "addressed": false, * "reason": "Needs a new case; a fix round cannot create cases"}]}}. Without {@code qualityScores} the case is @@ -24,12 +24,7 @@ @Builder @NoArgsConstructor @AllArgsConstructor -public class TmsTestCaseFixedVersionRQ { - - @NotNull - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, - description = "The push (fix round) this answers; the job received it as an input") - private Long fixPushId; +public class TmsFixRoundResultRQ { @Schema(description = "The iteration the agent reported for this round (a re-run of the one that produced the case)") private Long pipelineIterationId; diff --git a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java index 4115edc598..406332f180 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/dto/TmsReviewCommentRS.java @@ -42,7 +42,7 @@ public class TmsReviewCommentRS { private String agentReason; /** - * The push that carried the comment to the agent; the agent reads the comments of its job by it. + * The fix round that carried the comment to the agent; the agent reads the comments of its job by it. */ - private Long fixPushId; + private Long fixRoundId; } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java index 446cfb4f1a..315e126a2e 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/mapper/TmsReviewCommentMapper.java @@ -25,7 +25,7 @@ public static TmsReviewCommentRS toRS(TmsTestCaseReviewComment comment) { .state(comment.getState()) .roundNumber(comment.getRoundNumber()) .agentReason(comment.getAgentReason()) - .fixPushId(comment.getFixPushId()) + .fixRoundId(comment.getFixRoundId()) .build(); } } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java index 92b638af55..4fc7ad07ce 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationContributor.java @@ -4,7 +4,7 @@ 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.PipelineStageContributor; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.PipelineIteration; @@ -17,7 +17,7 @@ /** * Labels the iteration of a fix round, so that it is not taken for a plain re-run in the list. *

- * Example: iteration #5 is the answer to round 1 of TC103 (it carries {@code fixPushId = 7}). This contributor + * Example: iteration #5 is the answer to round 1 of TC103 (it carries {@code fixRoundId = 7}). This contributor * owns no stage; it adds the iteration attribute {@code fixRound = "TC103 · round 1"} that the list shows next to * the other facts of the iteration, and a Compare against #4 can tell the two apart. */ @@ -27,7 +27,7 @@ public class FixRoundIterationContributor implements PipelineStageContributor { static final String FIX_ROUND_ATTRIBUTE_KEY = "fixRound"; - private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundRepository fixRoundRepository; private final TmsTestCaseRepository testCaseRepository; @Override @@ -42,14 +42,14 @@ public List contribute(PipelineIteration iteration, List iterationAttributes(PipelineIteration iteration, List stages) { - return FixRoundIterationSupport.fixPushId(iteration) - .flatMap(fixPushRepository::findById) - .map(push -> { - var testCaseName = testCaseRepository.findById(push.getTestCaseId()) + return FixRoundIterationSupport.fixRoundId(iteration) + .flatMap(fixRoundRepository::findById) + .map(round -> { + var testCaseName = testCaseRepository.findById(round.getTestCaseId()) .map(TmsTestCase::getDisplayId) - .orElse("Test Case " + push.getTestCaseId()); + .orElse("Test Case " + round.getTestCaseId()); return List.of(PipelineAttribute.builder().key(FIX_ROUND_ATTRIBUTE_KEY).label("Fix round") - .value(testCaseName + " · round " + push.getRoundNumber()) + .value(testCaseName + " · round " + round.getRoundNumber()) .type(AttributeType.META).valueType(AttributeValueType.TEXT).build()); }) .orElse(List.of()); diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java index ffb3a247b7..64b22d6882 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationSupport.java @@ -7,14 +7,14 @@ /** * How the agent tells ReportPortal that an iteration it reports is the answer to a fix round. *

- * Example: the fix job received {@code push_id=7}. It reports its iteration as a re-run of the one that produced - * the case, with the iteration attribute {@code {"key": "fixPushId", "value": "7", "type": "NONE"}}. From it + * Example: the fix job received {@code fix_round_id=7}. It reports its iteration as a re-run of the one that produced + * the case, with the iteration attribute {@code {"key": "fixRoundId", "value": "7", "type": "NONE"}}. From it * ReportPortal knows which round the iteration belongs to, ends the round when the work fails, and labels the * iteration "Fix round 1" in the list. */ final class FixRoundIterationSupport { - static final String FIX_PUSH_ID_ATTRIBUTE_KEY = "fixPushId"; + static final String FIX_ROUND_ID_ATTRIBUTE_KEY = "fixRoundId"; private FixRoundIterationSupport() { } @@ -22,12 +22,12 @@ private FixRoundIterationSupport() { /** * The id of the fix round the iteration answers, when the agent reported one. */ - static Optional fixPushId(PipelineIteration iteration) { + static Optional fixRoundId(PipelineIteration iteration) { if (iteration == null || iteration.getAttributes() == null || iteration.getAttributes().getItems() == null) { return Optional.empty(); } return iteration.getAttributes().getItems().stream() - .filter(attribute -> FIX_PUSH_ID_ATTRIBUTE_KEY.equals(attribute.getKey())) + .filter(attribute -> FIX_ROUND_ID_ATTRIBUTE_KEY.equals(attribute.getKey())) .map(PipelineAttribute::getValue) .findFirst() .flatMap(FixRoundIterationSupport::parseId); diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java index fffba7682a..1aea335e71 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundOnStageFailed.java @@ -10,7 +10,7 @@ /** * Ends a fix round as failed when the iteration the agent reported for it fails. *

- * Example: the agent reported iteration #5 for round 1 of TC103 (attribute {@code fixPushId = 7}) and its Fix + * Example: the agent reported iteration #5 for round 1 of TC103 (attribute {@code fixRoundId = 7}) and its Fix * stage fails. The round ends as failed with the reason "Stage Fix failed", the comments of TC103 return to "not * sent" and the editor can push again. A failed Grade stage does not count: it does not fail the iteration, and * the round still ends with the fixed version the agent sends ("Fixed - Grade failed"). @@ -27,8 +27,8 @@ public List onStageReported(StageReport report) { if (stage.getStatusCategory() != StatusCategory.FAILED || !stage.isAffectsIterationStatus()) { return List.of(); } - FixRoundIterationSupport.fixPushId(stage.getIteration()) - .ifPresent(pushId -> fixRoundService.failRound(pushId, "Stage '" + stage.getName() + "' failed")); + FixRoundIterationSupport.fixRoundId(stage.getIteration()) + .ifPresent(roundId -> fixRoundService.failRound(roundId, "Stage '" + stage.getName() + "' failed")); return List.of(); } } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixRoundTimeoutJob.java similarity index 60% rename from src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java rename to src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixRoundTimeoutJob.java index ad2a11c4a1..1d46fc38d3 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixPushTimeoutJob.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsFixRoundTimeoutJob.java @@ -1,7 +1,7 @@ package com.epam.reportportal.base.core.tms.pipeline; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import java.time.Duration; import java.time.Instant; import lombok.extern.slf4j.Slf4j; @@ -15,26 +15,26 @@ * Example: with the default timeout of 30 minutes, a round pushed at 10:00 that has no fixed version and no * failed iteration by 10:30 ends as failed ("The agent did not answer in 30 minutes"); its comments return to * "not sent" and the editor can push again or discard them. The timeout is set with - * {@code rp.tms.fix-push.timeout}, how often it is checked with {@code rp.tms.fix-push.check-interval}. + * {@code rp.tms.fix-round.timeout}, how often it is checked with {@code rp.tms.fix-round.check-interval}. */ @Slf4j @Component -public class TmsFixPushTimeoutJob { +public class TmsFixRoundTimeoutJob { - private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundRepository fixRoundRepository; private final TmsTestCaseFixRoundService fixRoundService; private final Duration timeout; - public TmsFixPushTimeoutJob(TmsTestCaseFixPushRepository fixPushRepository, + public TmsFixRoundTimeoutJob(TmsTestCaseFixRoundRepository fixRoundRepository, TmsTestCaseFixRoundService fixRoundService, - @Value("${rp.tms.fix-push.timeout:PT30M}") Duration timeout) { - this.fixPushRepository = fixPushRepository; + @Value("${rp.tms.fix-round.timeout:PT30M}") Duration timeout) { + this.fixRoundRepository = fixRoundRepository; this.fixRoundService = fixRoundService; this.timeout = timeout; } - @Scheduled(fixedDelayString = "${rp.tms.fix-push.check-interval:PT1M}", - initialDelayString = "${rp.tms.fix-push.check-interval:PT1M}") + @Scheduled(fixedDelayString = "${rp.tms.fix-round.check-interval:PT1M}", + initialDelayString = "${rp.tms.fix-round.check-interval:PT1M}") public void failUnansweredRounds() { failUnansweredRounds(Instant.now()); } @@ -45,11 +45,11 @@ public void failUnansweredRounds() { * @return how many rounds were ended */ int failUnansweredRounds(Instant now) { - var overdue = fixPushRepository.findByStatusAndRequestedAtBefore(TmsFixPushStatus.PENDING, now.minus(timeout)); - for (var push : overdue) { - log.warn("Fix round {} of test case {} got no answer in {}; ending it as failed", push.getId(), - push.getTestCaseId(), timeout); - fixRoundService.failRound(push.getId(), "The agent did not answer in " + timeout.toMinutes() + " minutes"); + var overdue = fixRoundRepository.findByStatusAndRequestedAtBefore(TmsFixRoundStatus.PENDING, now.minus(timeout)); + for (var round : overdue) { + log.warn("Fix round {} of test case {} got no answer in {}; ending it as failed", round.getId(), + round.getTestCaseId(), timeout); + fixRoundService.failRound(round.getId(), "The agent did not answer in " + timeout.toMinutes() + " minutes"); } return overdue.size(); } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java index 44231d2811..d473d456b0 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundService.java @@ -3,19 +3,19 @@ import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.NOT_FOUND; import static com.epam.reportportal.base.infrastructure.rules.exception.ErrorType.TEST_CASE_REVIEW_CONFLICT; -import com.epam.reportportal.base.core.tms.dto.TmsTestCaseFixedVersionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsFixRoundResultRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; import com.epam.reportportal.base.core.tms.service.TmsTestCaseQualityService; import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; import com.epam.reportportal.base.core.tms.service.TmsTestCaseVersionService; import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; @@ -31,9 +31,9 @@ import org.springframework.transaction.annotation.Transactional; /** - * Ends a fix round of a test case: either with the fixed version the agent sends, or as failed. + * Ends a fix round of a test case: either with the result the agent sends, or as failed. *

- * Example: TC103 was pushed (round 1) with two comments. The agent sends the fixed steps, a new grade of 88 and + * Example: TC103 was pushed to the agent (round 1) with two comments. The agent sends the fixed steps, a new grade of 88 and * the answers: the case gets a new default version, becomes Draft ("Modified by agent") with the evaluation 88, * the applied comment is Addressed (round 1) and the other is Not addressed with the reason of the agent. Then * Auto-Ready may promote the case. If instead the job fails or never answers, {@link #failRound} returns the @@ -46,7 +46,7 @@ public class TmsTestCaseFixRoundService { static final String UNANSWERED_REASON = "The agent did not answer this comment"; private final TmsTestCaseRepository testCaseRepository; - private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundRepository fixRoundRepository; private final TmsTestCaseReviewCommentRepository commentRepository; private final PipelineIterationRepository iterationRepository; private final TmsTestCaseVersionService versionService; @@ -55,19 +55,19 @@ public class TmsTestCaseFixRoundService { private final ApplicationEventPublisher eventPublisher; /** - * Replaces the content of the pushed test case with the version fixed by the agent and records the outcome of - * the round, all in one transaction. + * Ends the round with the result of the agent: replaces the content of the test case with the version fixed by + * the agent and records the outcome of the round, all in one transaction. * * @return the test case as it is after the fix */ @Transactional - public TmsTestCaseRS applyFixedVersion(long projectId, Long testCaseId, TmsTestCaseFixedVersionRQ rq) { - var push = fixPushRepository.findByIdForUpdate(rq.getFixPushId()) + public TmsTestCaseRS applyResult(long projectId, Long testCaseId, Long fixRoundId, TmsFixRoundResultRQ rq) { + var round = fixRoundRepository.findByIdForUpdate(fixRoundId) .filter(found -> found.getProjectId().equals(projectId) && found.getTestCaseId().equals(testCaseId)) - .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Fix round '" + rq.getFixPushId() + "'")); - if (push.getStatus() != TmsFixPushStatus.PENDING) { + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Fix round '" + fixRoundId + "'")); + if (round.getStatus() != TmsFixRoundStatus.PENDING) { throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, - "Fix round '" + push.getId() + "' has already ended as " + push.getStatus()); + "Fix round '" + round.getId() + "' has already ended as " + round.getStatus()); } var testCase = testCaseRepository.findByProjectIdAndId(projectId, testCaseId) .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Test Case '" + testCaseId + "'")); @@ -86,15 +86,15 @@ public TmsTestCaseRS applyFixedVersion(long projectId, Long testCaseId, TmsTestC testCase.setModifiedByAgent(true); testCaseRepository.save(testCase); - markCommentsAnswered(push, rq.getCommentResults()); + markCommentsAnswered(round, rq.getCommentResults()); - push.setStatus(graded ? TmsFixPushStatus.FIXED : TmsFixPushStatus.FIXED_GRADE_FAILED); - push.setResultIterationId(rq.getPipelineIterationId()); - push.setFinishedAt(Instant.now()); - fixPushRepository.save(push); + round.setStatus(graded ? TmsFixRoundStatus.FIXED : TmsFixRoundStatus.FIXED_GRADE_FAILED); + round.setResultIterationId(rq.getPipelineIterationId()); + round.setFinishedAt(Instant.now()); + fixRoundRepository.save(round); if (graded) { - promoteWhenAutoReadyAllows(projectId, testCaseId, push); + promoteWhenAutoReadyAllows(projectId, testCaseId, round); } eventPublisher.publishEvent(new TmsTestCasesLifecycleChangedEvent(List.of(testCaseId))); return testCaseService.getById(projectId, testCaseId); @@ -105,33 +105,33 @@ public TmsTestCaseRS applyFixedVersion(long projectId, Long testCaseId, TmsTestC * the editor can push again or discard them. Does nothing when the round has already ended. */ @Transactional - public void failRound(Long fixPushId, String reason) { - var push = fixPushRepository.findByIdForUpdate(fixPushId).orElse(null); - if (push == null || push.getStatus() != TmsFixPushStatus.PENDING) { + public void failRound(Long fixRoundId, String reason) { + var round = fixRoundRepository.findByIdForUpdate(fixRoundId).orElse(null); + if (round == null || round.getStatus() != TmsFixRoundStatus.PENDING) { return; } - for (var comment : commentRepository.findByFixPushId(push.getId())) { + for (var comment : commentRepository.findByFixRoundId(round.getId())) { comment.setState(TmsReviewCommentState.NOT_SENT); - comment.setFixPushId(null); + comment.setFixRoundId(null); comment.setRoundNumber(null); commentRepository.save(comment); } - push.setStatus(TmsFixPushStatus.FAILED); - push.setFailureReason(reason); - push.setFinishedAt(Instant.now()); - fixPushRepository.save(push); - eventPublisher.publishEvent(new TmsTestCasesLifecycleChangedEvent(List.of(push.getTestCaseId()))); + round.setStatus(TmsFixRoundStatus.FAILED); + round.setFailureReason(reason); + round.setFinishedAt(Instant.now()); + fixRoundRepository.save(round); + eventPublisher.publishEvent(new TmsTestCasesLifecycleChangedEvent(List.of(round.getTestCaseId()))); } - private void markCommentsAnswered(TmsTestCaseFixPush push, List results) { - Map resultsByCommentId = results == null ? Map.of() - : results.stream().collect(Collectors.toMap(TmsTestCaseFixedVersionRQ.CommentResult::getCommentId, + private void markCommentsAnswered(TmsTestCaseFixRound round, List results) { + Map resultsByCommentId = results == null ? Map.of() + : results.stream().collect(Collectors.toMap(TmsFixRoundResultRQ.CommentResult::getCommentId, Function.identity(), (first, second) -> second)); - for (TmsTestCaseReviewComment comment : commentRepository.findByFixPushId(push.getId())) { + for (TmsTestCaseReviewComment comment : commentRepository.findByFixRoundId(round.getId())) { var result = resultsByCommentId.get(comment.getId()); var addressed = result != null && result.isAddressed(); comment.setState(addressed ? TmsReviewCommentState.ADDRESSED : TmsReviewCommentState.NOT_ADDRESSED); - comment.setRoundNumber(push.getRoundNumber()); + comment.setRoundNumber(round.getRoundNumber()); comment.setAgentReason(addressed ? null : (result != null && result.getReason() != null ? result.getReason() : UNANSWERED_REASON)); commentRepository.save(comment); @@ -141,8 +141,8 @@ private void markCommentsAnswered(TmsTestCaseFixPush push, List AutoReadySettings.of(iteration.getPipeline())) .filter(AutoReadySettings::active) .ifPresent(settings -> testCaseService.applyAutoReady(projectId, testCaseId, settings.threshold())); diff --git a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundStartService.java similarity index 75% rename from src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java rename to src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundStartService.java index 2b7c920c81..8b681ef2a2 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentService.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundStartService.java @@ -7,22 +7,22 @@ import com.epam.reportportal.base.core.aifactory.connector.CiRunRequest; import com.epam.reportportal.base.core.aifactory.connector.CiTriggerConnector; import com.epam.reportportal.base.core.aifactory.model.PipelineExecution; -import com.epam.reportportal.base.core.tms.dto.TmsFixPushRS; +import com.epam.reportportal.base.core.tms.dto.TmsFixRoundRS; import com.epam.reportportal.base.core.tms.mapper.TmsReviewCommentMapper; import com.epam.reportportal.base.infrastructure.persistence.dao.IntegrationRepository; 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.dao.tms.PipelineStageTestCaseRepository; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; 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.persistence.entity.tms.TmsTestCase; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; @@ -42,26 +42,26 @@ /** * "Push to agent": sends the unsent review comments of an AI test case to the factory by starting its fix job. *

- * Example: TC103 came out of iteration #4 and has two unsent comments. The push records round 1 as pending, + * Example: TC103 came out of iteration #4 and has two unsent comments. The call records round 1 as pending, * marks the comments as sent and starts the fix workflow of the repository of iteration #4 with the inputs - * {@code test_case_id=103}, {@code push_id=7} and {@code previous_iteration_id=41}. The agent reads the comments - * of push 7 from ReportPortal, fixes the case and reports its work as a new iteration, a re-run of #4. If the job + * {@code test_case_id=103}, {@code fix_round_id=7} and {@code previous_iteration_id=41}. The agent reads the comments + * of round 7 from ReportPortal, fixes the case and reports its work as a new iteration, a re-run of #4. If the job * cannot be started, nothing is recorded and the comments stay "not sent". *

* Deliberately not one transaction: the CI call in the middle must not run inside a database transaction. The - * round is recorded first (so a second push sees it in flight), then the job is started, and the record is + * round is recorded first (so a second "Push to agent" sees it in flight), then the job is started, and the record is * undone if starting fails. */ @Service -public class TmsTestCasePushToAgentService { +public class TmsTestCaseFixRoundStartService { static final String INPUT_TEST_CASE_ID = "test_case_id"; - static final String INPUT_PUSH_ID = "push_id"; + static final String INPUT_FIX_ROUND_ID = "fix_round_id"; static final String INPUT_PREVIOUS_ITERATION_ID = "previous_iteration_id"; private final TmsTestCaseRepository testCaseRepository; private final TmsTestCaseReviewCommentRepository commentRepository; - private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundRepository fixRoundRepository; private final PipelineStageTestCaseRepository stageTestCaseRepository; private final PipelineIterationRepository iterationRepository; private final PipelineStageRepository stageRepository; @@ -69,9 +69,9 @@ public class TmsTestCasePushToAgentService { private final Map connectorsByType; private final TransactionTemplate transactionTemplate; - public TmsTestCasePushToAgentService(TmsTestCaseRepository testCaseRepository, + public TmsTestCaseFixRoundStartService(TmsTestCaseRepository testCaseRepository, TmsTestCaseReviewCommentRepository commentRepository, - TmsTestCaseFixPushRepository fixPushRepository, + TmsTestCaseFixRoundRepository fixRoundRepository, PipelineStageTestCaseRepository stageTestCaseRepository, PipelineIterationRepository iterationRepository, PipelineStageRepository stageRepository, @@ -80,7 +80,7 @@ public TmsTestCasePushToAgentService(TmsTestCaseRepository testCaseRepository, PlatformTransactionManager transactionManager) { this.testCaseRepository = testCaseRepository; this.commentRepository = commentRepository; - this.fixPushRepository = fixPushRepository; + this.fixRoundRepository = fixRoundRepository; this.stageTestCaseRepository = stageTestCaseRepository; this.iterationRepository = iterationRepository; this.stageRepository = stageRepository; @@ -96,33 +96,33 @@ public TmsTestCasePushToAgentService(TmsTestCaseRepository testCaseRepository, * * @return the round that was started */ - public TmsFixPushRS push(long projectId, Long testCaseId, Long userId) { + public TmsFixRoundRS start(long projectId, Long testCaseId, Long userId) { var claimed = transactionTemplate.execute(status -> claim(projectId, testCaseId, userId)); String workflowUrl; try { workflowUrl = claimed.connector().triggerRun(claimed.integration(), claimed.runRequest()); } catch (RuntimeException e) { - transactionTemplate.executeWithoutResult(status -> release(claimed.pushId())); + transactionTemplate.executeWithoutResult(status -> release(claimed.roundId())); throw e; } - return transactionTemplate.execute(status -> recordStarted(claimed.pushId(), workflowUrl)); + return transactionTemplate.execute(status -> recordStarted(claimed.roundId(), workflowUrl)); } /** * The round and the comments it carries; this is what the fix job reads. */ - public TmsFixPushRS getPush(long projectId, Long testCaseId, Long pushId) { - var push = fixPushRepository.findById(pushId) + public TmsFixRoundRS getRound(long projectId, Long testCaseId, Long roundId) { + var round = fixRoundRepository.findById(roundId) .filter(found -> found.getProjectId().equals(projectId) && found.getTestCaseId().equals(testCaseId)) - .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Fix round '" + pushId + "'")); - return toRS(push, commentRepository.findByFixPushId(pushId)); + .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Fix round '" + roundId + "'")); + return toRS(round, commentRepository.findByFixRoundId(roundId)); } private Claimed claim(long projectId, Long testCaseId, Long userId) { var testCase = testCaseRepository.findByProjectIdAndId(projectId, testCaseId) .orElseThrow(() -> new ReportPortalException(NOT_FOUND, "Test Case '" + testCaseId + "'")); - assertCanBePushed(testCase); + assertCanStartRound(testCase); var unsent = commentRepository.findByTestCaseIdAndState(testCaseId, TmsReviewCommentState.NOT_SENT); if (unsent.isEmpty()) { throw new ReportPortalException(BAD_REQUEST_ERROR, "Add at least one review comment before pushing to the agent"); @@ -134,39 +134,39 @@ private Claimed claim(long projectId, Long testCaseId, Long userId) { var project = iterationRepository.findById(previousIterationId).orElseThrow().getPipeline().getProject(); var integration = resolveIntegration(project, connector); - var push = new TmsTestCaseFixPush(); - push.setTestCaseId(testCaseId); - push.setProjectId(projectId); - push.setRoundNumber(fixPushRepository.findLastRoundNumber(testCaseId) + 1); - push.setStatus(TmsFixPushStatus.PENDING); - push.setPreviousIterationId(previousIterationId); - push.setRequestedBy(userId); - push.setRequestedAt(Instant.now()); + var round = new TmsTestCaseFixRound(); + round.setTestCaseId(testCaseId); + round.setProjectId(projectId); + round.setRoundNumber(fixRoundRepository.findLastRoundNumber(testCaseId) + 1); + round.setStatus(TmsFixRoundStatus.PENDING); + round.setPreviousIterationId(previousIterationId); + round.setRequestedBy(userId); + round.setRequestedAt(Instant.now()); try { - push = fixPushRepository.saveAndFlush(push); + round = fixRoundRepository.saveAndFlush(round); } catch (DataIntegrityViolationException e) { throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, "The agent is already fixing this Test Case"); } for (var comment : unsent) { comment.setState(TmsReviewCommentState.SENT); - comment.setFixPushId(push.getId()); + comment.setFixRoundId(round.getId()); commentRepository.save(comment); } var inputs = new LinkedHashMap(); inputs.put(INPUT_TEST_CASE_ID, String.valueOf(testCaseId)); - inputs.put(INPUT_PUSH_ID, String.valueOf(push.getId())); + inputs.put(INPUT_FIX_ROUND_ID, String.valueOf(round.getId())); inputs.put(INPUT_PREVIOUS_ITERATION_ID, String.valueOf(previousIterationId)); var parameters = execution.getRetryParameters(); var runRequest = new CiRunRequest(parameters.get("repo"), parameters.get("workflowRef"), inputs); - return new Claimed(push.getId(), connector, integration, runRequest); + return new Claimed(round.getId(), connector, integration, runRequest); } - private void assertCanBePushed(TmsTestCase testCase) { + private void assertCanStartRound(TmsTestCase testCase) { if (testCase.getOrigin() != TmsTestCaseOrigin.AI) { throw new ReportPortalException(BAD_REQUEST_ERROR, "Only AI test cases can be pushed to the agent"); } - if (fixPushRepository.existsByTestCaseIdAndStatus(testCase.getId(), TmsFixPushStatus.PENDING)) { + if (fixRoundRepository.existsByTestCaseIdAndStatus(testCase.getId(), TmsFixRoundStatus.PENDING)) { throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, "The agent is already fixing this Test Case"); } } @@ -179,7 +179,7 @@ private Long findIterationThatProduced(Long testCaseId) { var iterationIds = stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(testCaseId)); if (iterationIds.isEmpty()) { throw new ReportPortalException(BAD_REQUEST_ERROR, - "This Test Case was not produced by a pipeline iteration, so there is no agent to push it to"); + "This Test Case was not produced by a pipeline iteration, so there is no agent to round it to"); } return Collections.max(iterationIds); } @@ -210,38 +210,38 @@ private Integration resolveIntegration(Project project, CiTriggerConnector conne /** * The job could not be started: the round never existed, and the comments are still "not sent". */ - private void release(Long pushId) { - for (TmsTestCaseReviewComment comment : commentRepository.findByFixPushId(pushId)) { + private void release(Long roundId) { + for (TmsTestCaseReviewComment comment : commentRepository.findByFixRoundId(roundId)) { comment.setState(TmsReviewCommentState.NOT_SENT); - comment.setFixPushId(null); + comment.setFixRoundId(null); commentRepository.save(comment); } - fixPushRepository.deleteById(pushId); + fixRoundRepository.deleteById(roundId); } - private TmsFixPushRS recordStarted(Long pushId, String workflowUrl) { - var push = fixPushRepository.findById(pushId).orElseThrow(); - push.setWorkflowUrl(workflowUrl); - fixPushRepository.save(push); - return toRS(push, commentRepository.findByFixPushId(pushId)); + private TmsFixRoundRS recordStarted(Long roundId, String workflowUrl) { + var round = fixRoundRepository.findById(roundId).orElseThrow(); + round.setWorkflowUrl(workflowUrl); + fixRoundRepository.save(round); + return toRS(round, commentRepository.findByFixRoundId(roundId)); } - private TmsFixPushRS toRS(TmsTestCaseFixPush push, List comments) { - return TmsFixPushRS.builder() - .id(push.getId()) - .testCaseId(push.getTestCaseId()) - .roundNumber(push.getRoundNumber()) - .status(push.getStatus()) - .previousIterationId(push.getPreviousIterationId()) - .resultIterationId(push.getResultIterationId()) - .requestedAt(push.getRequestedAt()) - .finishedAt(push.getFinishedAt()) - .workflowUrl(push.getWorkflowUrl()) - .failureReason(push.getFailureReason()) + private TmsFixRoundRS toRS(TmsTestCaseFixRound round, List comments) { + return TmsFixRoundRS.builder() + .id(round.getId()) + .testCaseId(round.getTestCaseId()) + .roundNumber(round.getRoundNumber()) + .status(round.getStatus()) + .previousIterationId(round.getPreviousIterationId()) + .resultIterationId(round.getResultIterationId()) + .requestedAt(round.getRequestedAt()) + .finishedAt(round.getFinishedAt()) + .workflowUrl(round.getWorkflowUrl()) + .failureReason(round.getFailureReason()) .comments(comments.stream().map(TmsReviewCommentMapper::toRS).toList()) .build(); } - private record Claimed(Long pushId, CiTriggerConnector connector, Integration integration, CiRunRequest runRequest) { + private record Claimed(Long roundId, CiTriggerConnector connector, Integration integration, CiRunRequest runRequest) { } } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java index f50cf8901c..4214337f77 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityService.java @@ -1,8 +1,8 @@ package com.epam.reportportal.base.core.tms.service; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; @@ -25,7 +25,7 @@ public class TmsTestCaseReadyEligibilityService { private final TmsTestCaseReviewCommentRepository commentRepository; - private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundRepository fixRoundRepository; /** * Which of the given test cases must not become Ready now: the ones with unsent review comments and the ones @@ -36,7 +36,7 @@ public Set findCasesThatCannotBecomeReady(Collection testCaseIds) { return Set.of(); } var blocked = new HashSet(commentRepository.findTestCaseIdsWithState(testCaseIds, TmsReviewCommentState.NOT_SENT)); - blocked.addAll(fixPushRepository.findTestCaseIdsWithStatus(testCaseIds, TmsFixPushStatus.PENDING)); + blocked.addAll(fixRoundRepository.findTestCaseIdsWithStatus(testCaseIds, TmsFixRoundStatus.PENDING)); return blocked; } @@ -48,7 +48,7 @@ public boolean canBecomeReady(Long testCaseId) { * Rejects making the test case Ready when it has unsent comments or is being fixed. */ public void assertCanBecomeReady(Long testCaseId) { - if (fixPushRepository.existsByTestCaseIdAndStatus(testCaseId, TmsFixPushStatus.PENDING)) { + if (fixRoundRepository.existsByTestCaseIdAndStatus(testCaseId, TmsFixRoundStatus.PENDING)) { throw new ReportPortalException(ErrorType.TEST_CASE_REVIEW_CONFLICT, "The agent is fixing Test Case '" + testCaseId + "'; wait for the fix round to end"); } diff --git a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java index d3ae63943d..e91f822cdd 100644 --- a/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java +++ b/src/main/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImpl.java @@ -9,12 +9,12 @@ import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRS; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseReviewRS; import com.epam.reportportal.base.core.tms.mapper.TmsReviewCommentMapper; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; @@ -32,7 +32,7 @@ public class TmsTestCaseReviewCommentServiceImpl implements TmsTestCaseReviewCom private final TmsTestCaseRepository testCaseRepository; private final TmsTestCaseReviewCommentRepository commentRepository; - private final TmsTestCaseFixPushRepository fixPushRepository; + private final TmsTestCaseFixRoundRepository fixRoundRepository; @Override @Transactional(readOnly = true) @@ -42,12 +42,12 @@ public TmsTestCaseReviewRS getReview(long projectId, Long testCaseId) { .map(TmsReviewCommentMapper::toRS) .toList(); var unsent = comments.stream().filter(comment -> comment.getState() == TmsReviewCommentState.NOT_SENT).count(); - var pendingPush = fixPushRepository.findByTestCaseIdAndStatus(testCaseId, TmsFixPushStatus.PENDING); + var pendingRound = fixRoundRepository.findByTestCaseIdAndStatus(testCaseId, TmsFixRoundStatus.PENDING); return TmsTestCaseReviewRS.builder() .comments(comments) .unsentCount((int) unsent) - .fixInProgress(pendingPush.isPresent()) - .fixRound(pendingPush.map(push -> push.getRoundNumber()).orElse(null)) + .fixInProgress(pendingRound.isPresent()) + .fixRound(pendingRound.map(round -> round.getRoundNumber()).orElse(null)) .build(); } @@ -107,7 +107,7 @@ private void assertAiCase(TmsTestCase testCase) { } private void assertNotBeingFixed(Long testCaseId) { - if (fixPushRepository.existsByTestCaseIdAndStatus(testCaseId, TmsFixPushStatus.PENDING)) { + if (fixRoundRepository.existsByTestCaseIdAndStatus(testCaseId, TmsFixRoundStatus.PENDING)) { throw new ReportPortalException(TEST_CASE_REVIEW_CONFLICT, "The agent is fixing this Test Case; its comments are read-only until the fix round ends"); } diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixPushRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixRoundRepository.java similarity index 56% rename from src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixPushRepository.java rename to src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixRoundRepository.java index a647a1588b..db085758e5 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixPushRepository.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseFixRoundRepository.java @@ -1,7 +1,7 @@ package com.epam.reportportal.base.infrastructure.persistence.dao.tms; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import jakarta.persistence.LockModeType; import java.time.Instant; import java.util.Collection; @@ -14,31 +14,31 @@ import org.springframework.stereotype.Repository; @Repository -public interface TmsTestCaseFixPushRepository extends JpaRepository { +public interface TmsTestCaseFixRoundRepository extends JpaRepository { - Optional findByTestCaseIdAndStatus(Long testCaseId, TmsFixPushStatus status); + Optional findByTestCaseIdAndStatus(Long testCaseId, TmsFixRoundStatus status); - boolean existsByTestCaseIdAndStatus(Long testCaseId, TmsFixPushStatus status); + boolean existsByTestCaseIdAndStatus(Long testCaseId, TmsFixRoundStatus status); - List findByTestCaseIdOrderByRoundNumberAsc(Long testCaseId); + List findByTestCaseIdOrderByRoundNumberAsc(Long testCaseId); - @Query("SELECT COALESCE(MAX(p.roundNumber), 0) FROM TmsTestCaseFixPush p WHERE p.testCaseId = :testCaseId") + @Query("SELECT COALESCE(MAX(p.roundNumber), 0) FROM TmsTestCaseFixRound p WHERE p.testCaseId = :testCaseId") int findLastRoundNumber(@Param("testCaseId") Long testCaseId); /** * The ids of the test cases among the given ones that have a round in the given status. */ - @Query("SELECT p.testCaseId FROM TmsTestCaseFixPush p WHERE p.status = :status AND p.testCaseId IN :testCaseIds") + @Query("SELECT p.testCaseId FROM TmsTestCaseFixRound p WHERE p.status = :status AND p.testCaseId IN :testCaseIds") List findTestCaseIdsWithStatus(@Param("testCaseIds") Collection testCaseIds, - @Param("status") TmsFixPushStatus status); + @Param("status") TmsFixRoundStatus status); @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("SELECT p FROM TmsTestCaseFixPush p WHERE p.id = :id") - Optional findByIdForUpdate(@Param("id") Long id); + @Query("SELECT p FROM TmsTestCaseFixRound p WHERE p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); /** * Rounds in the given status that were requested before the given moment, e.g. the pending ones the agent * did not answer in time. */ - List findByStatusAndRequestedAtBefore(TmsFixPushStatus status, Instant requestedBefore); + List findByStatusAndRequestedAtBefore(TmsFixRoundStatus status, Instant requestedBefore); } diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java index ea4bf2ca18..333a70c87e 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/tms/TmsTestCaseReviewCommentRepository.java @@ -17,7 +17,7 @@ public interface TmsTestCaseReviewCommentRepository extends JpaRepository findByTestCaseIdAndState(Long testCaseId, TmsReviewCommentState state); - List findByFixPushId(Long fixPushId); + List findByFixRoundId(Long fixRoundId); long countByTestCaseIdAndState(Long testCaseId, TmsReviewCommentState state); diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixRound.java similarity index 93% rename from src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java rename to src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixRound.java index bd01366c52..9e7a15c9b1 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixPush.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseFixRound.java @@ -1,7 +1,7 @@ package com.epam.reportportal.base.infrastructure.persistence.entity.tms; import com.epam.reportportal.base.infrastructure.persistence.dao.converters.JpaInstantConverter; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; @@ -27,12 +27,12 @@ * has at most one {@code PENDING} round. */ @Entity -@Table(name = "tms_test_case_fix_push", schema = "public") +@Table(name = "tms_test_case_fix_round", schema = "public") @Getter @Setter @NoArgsConstructor @AllArgsConstructor -public class TmsTestCaseFixPush implements Serializable { +public class TmsTestCaseFixRound implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -50,7 +50,7 @@ public class TmsTestCaseFixPush implements Serializable { @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) - private TmsFixPushStatus status; + private TmsFixRoundStatus status; /** * The iteration that produced the case; the agent reports its fix as a re-run of it. diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java index 7cbdcb96bf..d108851cb9 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/TmsTestCaseReviewComment.java @@ -68,10 +68,10 @@ public class TmsTestCaseReviewComment implements Serializable { private TmsReviewCommentState state = TmsReviewCommentState.NOT_SENT; /** - * The push that carried the comment to the agent. + * The fix round that carried the comment to the agent. */ - @Column(name = "fix_push_id") - private Long fixPushId; + @Column(name = "fix_round_id") + private Long fixRoundId; @Column(name = "round_number") private Integer roundNumber; diff --git a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixPushStatus.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixRoundStatus.java similarity index 72% rename from src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixPushStatus.java rename to src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixRoundStatus.java index c813ca9466..5b938e501c 100644 --- a/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixPushStatus.java +++ b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/entity/tms/enums/TmsFixRoundStatus.java @@ -3,11 +3,11 @@ /** * Where a fix round (one "Push to agent" of one test case) stands. *

- * Example: a push is {@code PENDING} while the agent works; it ends {@code FIXED} when the new version of the + * Example: a round is {@code PENDING} while the agent works; it ends {@code FIXED} when the new version of the * case arrives with a fresh grade, {@code FIXED_GRADE_FAILED} when the version arrives without one, or * {@code FAILED} when the job fails or does not answer in time. */ -public enum TmsFixPushStatus { +public enum TmsFixRoundStatus { PENDING, FIXED, FIXED_GRADE_FAILED, diff --git a/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java index 046902e5c0..8a2f5e18f7 100644 --- a/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java +++ b/src/test/java/com/epam/reportportal/base/core/aifactory/connector/CiConnectorTriggerRunTest.java @@ -37,7 +37,7 @@ private Integration integrationWithParams(Map params) { private CiRunRequest fixRunRequest(String repo, String workflowRef) { var inputs = new LinkedHashMap(); inputs.put("test_case_id", "103"); - inputs.put("push_id", "7"); + inputs.put("fix_round_id", "7"); return new CiRunRequest(repo, workflowRef, inputs); } @@ -58,7 +58,7 @@ void gitHubTriggerRun_ShouldDispatchTheWorkflowWithTheInputsAndReturnTheWorkflow assertEquals("Bearer gh-token", headersCaptor.getValue().get("Authorization")); assertTrue(bodyCaptor.getValue().contains("\"ref\":\"main\"")); assertTrue(bodyCaptor.getValue().contains("\"test_case_id\":\"103\"")); - assertTrue(bodyCaptor.getValue().contains("\"push_id\":\"7\"")); + assertTrue(bodyCaptor.getValue().contains("\"fix_round_id\":\"7\"")); } @Test @@ -106,7 +106,7 @@ void gitLabTriggerRun_ShouldTriggerThePipelineWithTheInputsAsVariablesAndReturnI assertEquals("https://gitlab.com/api/v4/projects/42/trigger/pipeline", urlCaptor.getValue()); assertTrue(bodyCaptor.getValue().contains("\"ref\":\"main\"")); assertTrue(bodyCaptor.getValue().contains("\"TEST_CASE_ID\":\"103\"")); - assertTrue(bodyCaptor.getValue().contains("\"PUSH_ID\":\"7\"")); + assertTrue(bodyCaptor.getValue().contains("\"FIX_ROUND_ID\":\"7\"")); } @Test diff --git a/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java index f4bbd1bb5e..5c2e8e1b56 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/controller/integration/TestCaseFixRoundE2eIntegrationTest.java @@ -95,10 +95,10 @@ static void restoreTimeZone() { void fullScenario_FromTheFirstCommentToTheFixedCaseAndAFailedRound() throws Exception { step1_aGenerationIterationProducedAnAiDraftCase(); step2_editorCommentsAndCannotApproveWhileCommentsAreUnsent(); - step3_pushToAgentStartsTheFixJobWithIdsOnly(); + step3_startingTheRoundStartsTheFixJobWithIdsOnly(); step4_whileTheAgentIsFixingTheCaseIsLocked(); - step5_theJobReadsItsCommentsAndReportsItsIteration(); - step6_theFixedVersionArrivesAndAutoReadyPromotesTheCase(); + step5_theJobReadsItsRoundAndReportsItsIteration(); + step6_theResultArrivesAndAutoReadyPromotesTheCase(); step7_theFixedCaseKeepsItsHistoryAndTheCommentsAreAnswered(); step8_theIterationsKnowTheRound(); step9_commentsNeverChangeTheLifecycleAndAFailedRoundGivesTheCommentsBack(); @@ -162,10 +162,10 @@ private void step2_editorCommentsAndCannotApproveWhileCommentsAreUnsent() throws // ------------------------------------------------------------------ step 3 - private void step3_pushToAgentStartsTheFixJobWithIdsOnly() throws Exception { + private void step3_startingTheRoundStartsTheFixJobWithIdsOnly() throws Exception { when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); - var round = send(post(reviewUrl("/push")), null, HttpStatus.OK); + var round = send(post(reviewUrl("/fix-round")), null, HttpStatus.OK); assertEquals(1, round.get("roundNumber").asInt()); assertEquals("PENDING", round.get("status").asText()); @@ -180,7 +180,7 @@ private void step3_pushToAgentStartsTheFixJobWithIdsOnly() throws Exception { assertEquals("https://api.github.com/repos/org/repo/actions/workflows/fix-test-case.yml/dispatches", url.getValue()); var inputs = mapper.readTree(body.getValue()).get("inputs"); assertEquals(String.valueOf(testCaseId), inputs.get("test_case_id").asText()); - assertEquals(round.get("id").asText(), inputs.get("push_id").asText()); + assertEquals(round.get("id").asText(), inputs.get("fix_round_id").asText()); assertEquals(String.valueOf(firstIterationId), inputs.get("previous_iteration_id").asText()); assertEquals(3, inputs.size(), "the comments themselves never travel through the workflow inputs"); } @@ -195,39 +195,39 @@ private void step4_whileTheAgentIsFixingTheCaseIsLocked() throws Exception { send(post(reviewUrl("/comment")), "{\"targetType\": \"PRECONDITION\", \"text\": \"one more\"}", HttpStatus.CONFLICT); send(delete(reviewUrl("/comment")), null, HttpStatus.CONFLICT); - send(post(reviewUrl("/push")), null, HttpStatus.CONFLICT); + send(post(reviewUrl("/fix-round")), null, HttpStatus.CONFLICT); send(patch(TEST_CASES + "/" + testCaseId), "{\"status\": \"READY\"}", HttpStatus.CONFLICT); } // ------------------------------------------------------------------ step 5 - private long pushId; + private long roundId; private long secondIterationId; - private JsonNode pushedComments; + private JsonNode roundComments; - private void step5_theJobReadsItsCommentsAndReportsItsIteration() throws Exception { - pushId = send(get(reviewUrl("")), null, HttpStatus.OK).get("comments").get(0).get("fixPushId").asLong(); - var round = send(get(reviewUrl("/push/" + pushId)), null, HttpStatus.OK); - pushedComments = round.get("comments"); - assertEquals(2, pushedComments.size()); - assertEquals("Name the exact toast text", pushedComments.get(0).get("text").asText()); + private void step5_theJobReadsItsRoundAndReportsItsIteration() throws Exception { + roundId = send(get(reviewUrl("")), null, HttpStatus.OK).get("comments").get(0).get("fixRoundId").asLong(); + var round = send(get(reviewUrl("/fix-round/" + roundId)), null, HttpStatus.OK); + roundComments = round.get("comments"); + assertEquals(2, roundComments.size()); + assertEquals("Name the exact toast text", roundComments.get(0).get("text").asText()); var iteration = send(post(PIPELINES + "/iteration").header("Idempotency-Key", "fix-round-fix-1"), """ {"name": "Test case generation", "type": "TEST_CASE_GENERATION", "iteration": {"startedAt": "2026-10-20T11:00:00Z", "rerunOfIterationId": %d, "attributes": [{"key": "requirement", "label": "Requirement", "value": "US-TMS-BLK-001", "type": "META"}, - {"key": "fixPushId", "label": "Fix round", "value": "%d", "type": "NONE"}], + {"key": "fixRoundId", "label": "Fix round", "value": "%d", "type": "NONE"}], "stages": [{"stageKey": "fix", "name": "Fix", "sequence": 1, "status": "Running", "execution": %s}]}} - """.formatted(firstIterationId, pushId, execution("201")), HttpStatus.CREATED); + """.formatted(firstIterationId, roundId, execution("201")), HttpStatus.CREATED); secondIterationId = iteration.get("id").asLong(); assertEquals(firstIterationId, iteration.get("rerunOf").get("id").asLong()); } // ------------------------------------------------------------------ step 6 - private void step6_theFixedVersionArrivesAndAutoReadyPromotesTheCase() throws Exception { - var fixed = send(post(TEST_CASES + "/" + testCaseId + "/fixed-version"), """ - {"fixPushId": %d, "pipelineIterationId": %d, "name": "Test Case Library. Bulk selection. 'Add to Test Plan' is disabled with no selection", + private void step6_theResultArrivesAndAutoReadyPromotesTheCase() throws Exception { + var fixed = send(put(reviewUrl("/fix-round/" + roundId + "/result")), """ + {"pipelineIterationId": %d, "name": "Test Case Library. Bulk selection. 'Add to Test Plan' is disabled with no selection", "manualScenario": {"manualScenarioType": "STEPS", "steps": [{"instructions": "Open the Library and select no Test Case", "expectedResult": "The bulk panel is hidden"}, {"instructions": "Look at the toast", "expectedResult": "The toast says 'Select at least one Test Case'"}]}, @@ -235,15 +235,15 @@ private void step6_theFixedVersionArrivesAndAutoReadyPromotesTheCase() throws Ex "generation": {"model": "auto (default)", "costUsd": 0.22}, "commentResults": [{"commentId": %d, "addressed": true}, {"commentId": %d, "addressed": false, "reason": "Needs a new case; a fix round cannot create cases"}]} - """.formatted(pushId, secondIterationId, scores(new int[] {15, 20, 18, 18, 14, 9}), - pushedComments.get(0).get("id").asLong(), pushedComments.get(1).get("id").asLong()), HttpStatus.OK); + """.formatted(secondIterationId, scores(new int[] {15, 20, 18, 18, 14, 9}), + roundComments.get(0).get("id").asLong(), roundComments.get(1).get("id").asLong()), HttpStatus.OK); assertEquals(94, fixed.get("metrics").get("overallScore").asInt()); assertEquals("READY", fixed.get("status").asText(), "94 >= 90: promoted by Auto-Ready after the update"); assertNotEquals(firstVersionId, fixed.get("defaultVersionId").asLong(), "the fix is a new default version"); assertEquals(2, fixed.get("manualScenario").get("steps").size()); - send(post(TEST_CASES + "/" + testCaseId + "/fixed-version"), "{\"fixPushId\": " + pushId + ", \"manualScenario\": " - + "{\"manualScenarioType\": \"STEPS\", \"steps\": []}}", HttpStatus.CONFLICT); + send(put(reviewUrl("/fix-round/" + roundId + "/result")), + "{\"manualScenario\": {\"manualScenarioType\": \"STEPS\", \"steps\": []}}", HttpStatus.CONFLICT); } // ------------------------------------------------------------------ step 7 @@ -298,31 +298,31 @@ private void step9_commentsNeverChangeTheLifecycleAndAFailedRoundGivesTheComment reset(ciHttpClient); when(ciHttpClient.postForStatus(anyString(), anyMap(), anyString())).thenReturn(""); - var round = send(post(reviewUrl("/push")), null, HttpStatus.OK); + var round = send(post(reviewUrl("/fix-round")), null, HttpStatus.OK); assertEquals(2, round.get("roundNumber").asInt()); assertEquals(secondIterationId, round.get("previousIterationId").asLong(), "round 2 follows the iteration that last fixed the case"); - var secondPushId = round.get("id").asLong(); + var secondRoundId = round.get("id").asLong(); var third = send(post(PIPELINES + "/iteration").header("Idempotency-Key", "fix-round-fix-2"), """ {"name": "Test case generation", "type": "TEST_CASE_GENERATION", "iteration": {"startedAt": "2026-10-20T12:00:00Z", "rerunOfIterationId": %d, - "attributes": [{"key": "fixPushId", "value": "%d", "type": "NONE"}], + "attributes": [{"key": "fixRoundId", "value": "%d", "type": "NONE"}], "stages": [{"stageKey": "fix", "name": "Fix", "sequence": 1, "status": "Failed", "message": "The agent could not apply the comment"}]}} - """.formatted(secondIterationId, secondPushId), HttpStatus.CREATED); + """.formatted(secondIterationId, secondRoundId), HttpStatus.CREATED); assertEquals("FAILED", third.get("statusCategory").asText()); var review = send(get(reviewUrl("")), null, HttpStatus.OK); assertFalse(review.get("fixInProgress").asBoolean()); assertEquals(1, review.get("unsentCount").asInt(), "the comment of the failed round is not sent again"); assertEquals("READY", libraryCase().get("status").asText(), "a failed round leaves the case as it was"); - assertEquals("FAILED", jdbcTemplate.queryForObject("select status from tms_test_case_fix_push where id = ?", - String.class, secondPushId)); + assertEquals("FAILED", jdbcTemplate.queryForObject("select status from tms_test_case_fix_round where id = ?", + String.class, secondRoundId)); assertEquals(2, count("select count(*) from tms_test_case_version where test_case_id = ?", testCaseId), "a failed round creates no version"); - send(post(reviewUrl("/push")), null, HttpStatus.OK); + send(post(reviewUrl("/fix-round")), null, HttpStatus.OK); send(delete(reviewUrl("/comment")), null, HttpStatus.CONFLICT); } diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java index 7e49d5958e..4eb9436e32 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/FixRoundIterationHooksTest.java @@ -11,14 +11,14 @@ 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.PipelineStageReportedHandler.StageReport; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; 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.TmsTestCase; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; import java.time.Duration; import java.time.Instant; import java.util.List; @@ -39,7 +39,7 @@ class FixRoundIterationHooksTest { @Mock private TmsTestCaseFixRoundService fixRoundService; @Mock - private TmsTestCaseFixPushRepository fixPushRepository; + private TmsTestCaseFixRoundRepository fixRoundRepository; @Mock private TmsTestCaseRepository testCaseRepository; @@ -48,10 +48,10 @@ class FixRoundIterationHooksTest { @InjectMocks private FixRoundIterationContributor contributor; - private static PipelineIteration iterationWithFixPush(String pushId) { + private static PipelineIteration iterationAnsweringRound(String roundId) { var iteration = new PipelineIteration(); iteration.setAttributes(new PipelineAttributes(List.of( - PipelineAttribute.builder().key("fixPushId").value(pushId).type(AttributeType.NONE).build()))); + PipelineAttribute.builder().key("fixRoundId").value(roundId).type(AttributeType.NONE).build()))); return iteration; } @@ -66,7 +66,7 @@ private static PipelineStage stage(PipelineIteration iteration, StatusCategory c @Test void onStageReported_WhenAStageOfAFixIterationFails_ShouldEndTheRoundAsFailed() { - var stage = stage(iterationWithFixPush("7"), StatusCategory.FAILED, true); + var stage = stage(iterationAnsweringRound("7"), StatusCategory.FAILED, true); onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, false)); @@ -75,7 +75,7 @@ void onStageReported_WhenAStageOfAFixIterationFails_ShouldEndTheRoundAsFailed() @Test void onStageReported_WhenAStageThatDoesNotCountFails_ShouldLeaveTheRoundAlone() { - var stage = stage(iterationWithFixPush("7"), StatusCategory.FAILED, false); + var stage = stage(iterationAnsweringRound("7"), StatusCategory.FAILED, false); onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, false)); @@ -93,7 +93,7 @@ void onStageReported_WhenTheIterationAnswersNoRound_ShouldDoNothing() { @Test void onStageReported_WhenTheStageDidNotFail_ShouldDoNothing() { - var stage = stage(iterationWithFixPush("7"), StatusCategory.PASSED, true); + var stage = stage(iterationAnsweringRound("7"), StatusCategory.PASSED, true); onStageFailed.onStageReported(new StageReport(1L, new Pipeline(), stage, true)); @@ -102,15 +102,15 @@ void onStageReported_WhenTheStageDidNotFail_ShouldDoNothing() { @Test void iterationAttributes_OfAFixIteration_ShouldLabelItWithTheCaseAndTheRound() { - var push = new TmsTestCaseFixPush(); - push.setTestCaseId(103L); - push.setRoundNumber(1); - when(fixPushRepository.findById(7L)).thenReturn(Optional.of(push)); + var round = new TmsTestCaseFixRound(); + round.setTestCaseId(103L); + round.setRoundNumber(1); + when(fixRoundRepository.findById(7L)).thenReturn(Optional.of(round)); var testCase = new TmsTestCase(); testCase.setDisplayId("TC103"); when(testCaseRepository.findById(103L)).thenReturn(Optional.of(testCase)); - var attributes = contributor.iterationAttributes(iterationWithFixPush("7"), List.of()); + var attributes = contributor.iterationAttributes(iterationAnsweringRound("7"), List.of()); assertEquals(1, attributes.size()); assertEquals("fixRound", attributes.get(0).getKey()); @@ -124,19 +124,19 @@ void iterationAttributes_OfAPlainIteration_ShouldAddNothing() { } @Test - void iterationAttributes_WithANonNumericPushId_ShouldAddNothing() { - assertTrue(contributor.iterationAttributes(iterationWithFixPush("seven"), List.of()).isEmpty()); + void iterationAttributes_WithANonNumericRoundId_ShouldAddNothing() { + assertTrue(contributor.iterationAttributes(iterationAnsweringRound("seven"), List.of()).isEmpty()); } @Test void timeoutJob_ShouldEndTheRoundsOlderThanTheTimeoutAndLeaveTheRest() { - var job = new TmsFixPushTimeoutJob(fixPushRepository, fixRoundService, Duration.ofMinutes(30)); + var job = new TmsFixRoundTimeoutJob(fixRoundRepository, fixRoundService, Duration.ofMinutes(30)); var now = Instant.parse("2026-10-20T10:30:00Z"); - var overdue = new TmsTestCaseFixPush(); + var overdue = new TmsTestCaseFixRound(); overdue.setId(7L); overdue.setTestCaseId(103L); - when(fixPushRepository.findByStatusAndRequestedAtBefore( - com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus.PENDING, + when(fixRoundRepository.findByStatusAndRequestedAtBefore( + com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus.PENDING, Instant.parse("2026-10-20T10:00:00Z"))).thenReturn(List.of(overdue)); var ended = job.failUnansweredRounds(now); diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java index ee96517cb6..258aa0121d 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundServiceTest.java @@ -14,7 +14,7 @@ import static org.mockito.Mockito.when; import com.epam.reportportal.base.core.tms.dto.TmsManualScenarioRQ; -import com.epam.reportportal.base.core.tms.dto.TmsTestCaseFixedVersionRQ; +import com.epam.reportportal.base.core.tms.dto.TmsFixRoundResultRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseQualityScoreRQ; import com.epam.reportportal.base.core.tms.dto.TmsTestCaseRS; import com.epam.reportportal.base.core.tms.event.TmsTestCasesLifecycleChangedEvent; @@ -22,17 +22,17 @@ import com.epam.reportportal.base.core.tms.service.TmsTestCaseService; import com.epam.reportportal.base.core.tms.service.TmsTestCaseVersionService; import com.epam.reportportal.base.infrastructure.persistence.dao.aifactory.PipelineIterationRepository; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; 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.persistence.entity.tms.TmsTestCase; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseVersion; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseStatus; import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; @@ -54,13 +54,13 @@ class TmsTestCaseFixRoundServiceTest { private static final long PROJECT_ID = 1L; private static final Long TEST_CASE_ID = 103L; - private static final Long PUSH_ID = 7L; + private static final Long FIX_ROUND_ID = 7L; private static final Long PREVIOUS_ITERATION_ID = 41L; @Mock private TmsTestCaseRepository testCaseRepository; @Mock - private TmsTestCaseFixPushRepository fixPushRepository; + private TmsTestCaseFixRoundRepository fixRoundRepository; @Mock private TmsTestCaseReviewCommentRepository commentRepository; @Mock @@ -78,7 +78,7 @@ class TmsTestCaseFixRoundServiceTest { private TmsTestCaseFixRoundService sut; private TmsTestCase testCase; - private TmsTestCaseFixPush push; + private TmsTestCaseFixRound round; private TmsTestCaseVersion newVersion; private TmsTestCaseReviewComment applied; private TmsTestCaseReviewComment notApplied; @@ -91,13 +91,13 @@ void setUp() { testCase.setName("Old title"); testCase.setStatus(TmsTestCaseStatus.READY); - push = new TmsTestCaseFixPush(); - push.setId(PUSH_ID); - push.setProjectId(PROJECT_ID); - push.setTestCaseId(TEST_CASE_ID); - push.setRoundNumber(1); - push.setStatus(TmsFixPushStatus.PENDING); - push.setPreviousIterationId(PREVIOUS_ITERATION_ID); + round = new TmsTestCaseFixRound(); + round.setId(FIX_ROUND_ID); + round.setProjectId(PROJECT_ID); + round.setTestCaseId(TEST_CASE_ID); + round.setRoundNumber(1); + round.setStatus(TmsFixRoundStatus.PENDING); + round.setPreviousIterationId(PREVIOUS_ITERATION_ID); newVersion = new TmsTestCaseVersion(); applied = sentComment(11L); @@ -110,17 +110,16 @@ private static TmsTestCaseReviewComment sentComment(Long id) { comment.setId(id); comment.setTestCaseId(TEST_CASE_ID); comment.setState(TmsReviewCommentState.SENT); - comment.setFixPushId(PUSH_ID); + comment.setFixRoundId(FIX_ROUND_ID); return comment; } - private TmsTestCaseFixedVersionRQ.CommentResult result(Long commentId, boolean addressed, String reason) { - return TmsTestCaseFixedVersionRQ.CommentResult.builder().commentId(commentId).addressed(addressed).reason(reason).build(); + private TmsFixRoundResultRQ.CommentResult result(Long commentId, boolean addressed, String reason) { + return TmsFixRoundResultRQ.CommentResult.builder().commentId(commentId).addressed(addressed).reason(reason).build(); } - private TmsTestCaseFixedVersionRQ fixedVersion(List scores) { - return TmsTestCaseFixedVersionRQ.builder() - .fixPushId(PUSH_ID) + private TmsFixRoundResultRQ resultOf(List scores) { + return TmsFixRoundResultRQ.builder() .pipelineIterationId(52L) .manualScenario(org.mockito.Mockito.mock(TmsManualScenarioRQ.class)) .name("New title") @@ -130,10 +129,10 @@ private TmsTestCaseFixedVersionRQ fixedVersion(List s } private void givenAPendingRoundOfAnExistingCase() { - when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + when(fixRoundRepository.findByIdForUpdate(FIX_ROUND_ID)).thenReturn(Optional.of(round)); when(testCaseRepository.findByProjectIdAndId(PROJECT_ID, TEST_CASE_ID)).thenReturn(Optional.of(testCase)); when(versionService.createNewDefaultVersion(eq(PROJECT_ID), eq(testCase), any(), eq(52L))).thenReturn(newVersion); - when(commentRepository.findByFixPushId(PUSH_ID)).thenReturn(List.of(applied, notApplied, unanswered)); + when(commentRepository.findByFixRoundId(FIX_ROUND_ID)).thenReturn(List.of(applied, notApplied, unanswered)); when(testCaseService.getById(PROJECT_ID, TEST_CASE_ID)).thenReturn(new TmsTestCaseRS()); } @@ -146,12 +145,12 @@ private void givenAutoReady(int threshold) { } @Test - void applyFixedVersion_ShouldCreateTheNewVersionGradeItAndSendTheCaseBackToDraft() { + void applyResult_ShouldCreateTheNewVersionGradeItAndSendTheCaseBackToDraft() { givenAPendingRoundOfAnExistingCase(); var scores = List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()); - var rq = fixedVersion(scores); + var rq = resultOf(scores); - sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, rq); + sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, rq); verify(versionService).createNewDefaultVersion(PROJECT_ID, testCase, rq.getManualScenario(), 52L); verify(qualityService).applyQualityScores(PROJECT_ID, newVersion, scores); @@ -159,17 +158,17 @@ void applyFixedVersion_ShouldCreateTheNewVersionGradeItAndSendTheCaseBackToDraft assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); assertTrue(testCase.isModifiedByAgent()); assertEquals("New title", testCase.getName()); - assertEquals(TmsFixPushStatus.FIXED, push.getStatus()); - assertEquals(52L, push.getResultIterationId()); - assertNotNull(push.getFinishedAt()); + assertEquals(TmsFixRoundStatus.FIXED, round.getStatus()); + assertEquals(52L, round.getResultIterationId()); + assertNotNull(round.getFinishedAt()); } @Test - void applyFixedVersion_ShouldMarkEachCommentWithItsAnswerAndTheRound() { + void applyResult_ShouldMarkEachCommentWithItsAnswerAndTheRound() { givenAPendingRoundOfAnExistingCase(); - sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, - fixedVersion(List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()))); + sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, + resultOf(List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()))); assertEquals(TmsReviewCommentState.ADDRESSED, applied.getState()); assertEquals(1, applied.getRoundNumber()); @@ -181,32 +180,32 @@ void applyFixedVersion_ShouldMarkEachCommentWithItsAnswerAndTheRound() { } @Test - void applyFixedVersion_WithAGrade_ShouldRunAutoReadyWithTheThresholdOfThePipeline() { + void applyResult_WithAGrade_ShouldRunAutoReadyWithTheThresholdOfThePipeline() { givenAPendingRoundOfAnExistingCase(); givenAutoReady(90); - sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, - fixedVersion(List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()))); + sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, + resultOf(List.of(TmsTestCaseQualityScoreRQ.builder().criterionKey("atomicity").score(15).build()))); verify(testCaseService).applyAutoReady(PROJECT_ID, TEST_CASE_ID, 90); } @Test - void applyFixedVersion_WithoutAGrade_ShouldEndAsFixedGradeFailedAndNotRunAutoReady() { + void applyResult_WithoutAGrade_ShouldEndAsFixedGradeFailedAndNotRunAutoReady() { givenAPendingRoundOfAnExistingCase(); - sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null)); + sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, resultOf(null)); - assertEquals(TmsFixPushStatus.FIXED_GRADE_FAILED, push.getStatus()); + assertEquals(TmsFixRoundStatus.FIXED_GRADE_FAILED, round.getStatus()); assertEquals(TmsTestCaseStatus.DRAFT, testCase.getStatus()); verify(testCaseService, never()).applyAutoReady(anyLong(), anyLong(), anyInt()); } @Test - void applyFixedVersion_ShouldTellTheReviewOfTheIterationsThatTheLifecycleChanged() { + void applyResult_ShouldTellTheReviewOfTheIterationsThatTheLifecycleChanged() { givenAPendingRoundOfAnExistingCase(); - sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null)); + sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, resultOf(null)); var event = ArgumentCaptor.forClass(Object.class); verify(eventPublisher).publishEvent(event.capture()); @@ -214,51 +213,51 @@ void applyFixedVersion_ShouldTellTheReviewOfTheIterationsThatTheLifecycleChanged } @Test - void applyFixedVersion_WhenTheRoundHasAlreadyEnded_ShouldBeAConflictAndChangeNothing() { - push.setStatus(TmsFixPushStatus.FAILED); - when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + void applyResult_WhenTheRoundHasAlreadyEnded_ShouldBeAConflictAndChangeNothing() { + round.setStatus(TmsFixRoundStatus.FAILED); + when(fixRoundRepository.findByIdForUpdate(FIX_ROUND_ID)).thenReturn(Optional.of(round)); var ex = assertThrows(ReportPortalException.class, - () -> sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null))); + () -> sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, resultOf(null))); assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); verify(versionService, never()).createNewDefaultVersion(anyLong(), any(), any(), any()); } @Test - void applyFixedVersion_ForARoundOfAnotherTestCase_ShouldBeNotFound() { - push.setTestCaseId(999L); - when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + void applyResult_ForARoundOfAnotherTestCase_ShouldBeNotFound() { + round.setTestCaseId(999L); + when(fixRoundRepository.findByIdForUpdate(FIX_ROUND_ID)).thenReturn(Optional.of(round)); var ex = assertThrows(ReportPortalException.class, - () -> sut.applyFixedVersion(PROJECT_ID, TEST_CASE_ID, fixedVersion(null))); + () -> sut.applyResult(PROJECT_ID, TEST_CASE_ID, FIX_ROUND_ID, resultOf(null))); assertEquals(ErrorType.NOT_FOUND.getCode(), ex.getErrorType().getCode()); } @Test void failRound_ShouldReturnTheCommentsToNotSentAndEndTheRoundAsFailed() { - when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); - when(commentRepository.findByFixPushId(PUSH_ID)).thenReturn(List.of(applied, notApplied)); + when(fixRoundRepository.findByIdForUpdate(FIX_ROUND_ID)).thenReturn(Optional.of(round)); + when(commentRepository.findByFixRoundId(FIX_ROUND_ID)).thenReturn(List.of(applied, notApplied)); - sut.failRound(PUSH_ID, "Stage 'Fix' failed"); + sut.failRound(FIX_ROUND_ID, "Stage 'Fix' failed"); - assertEquals(TmsFixPushStatus.FAILED, push.getStatus()); - assertEquals("Stage 'Fix' failed", push.getFailureReason()); - assertNotNull(push.getFinishedAt()); + assertEquals(TmsFixRoundStatus.FAILED, round.getStatus()); + assertEquals("Stage 'Fix' failed", round.getFailureReason()); + assertNotNull(round.getFinishedAt()); assertEquals(TmsReviewCommentState.NOT_SENT, applied.getState()); - assertNull(applied.getFixPushId()); + assertNull(applied.getFixRoundId()); assertEquals(TmsReviewCommentState.NOT_SENT, notApplied.getState()); } @Test void failRound_WhenTheRoundAlreadyEnded_ShouldDoNothing() { - push.setStatus(TmsFixPushStatus.FIXED); - when(fixPushRepository.findByIdForUpdate(PUSH_ID)).thenReturn(Optional.of(push)); + round.setStatus(TmsFixRoundStatus.FIXED); + when(fixRoundRepository.findByIdForUpdate(FIX_ROUND_ID)).thenReturn(Optional.of(round)); - sut.failRound(PUSH_ID, "late"); + sut.failRound(FIX_ROUND_ID, "late"); - assertEquals(TmsFixPushStatus.FIXED, push.getStatus()); + assertEquals(TmsFixRoundStatus.FIXED, round.getStatus()); verify(commentRepository, never()).save(any()); } } diff --git a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundStartServiceTest.java similarity index 72% rename from src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java rename to src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundStartServiceTest.java index 6eed897678..8cdf0d15cb 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCasePushToAgentServiceTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/pipeline/TmsTestCaseFixRoundStartServiceTest.java @@ -18,7 +18,7 @@ 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.dao.tms.PipelineStageTestCaseRepository; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; import com.epam.reportportal.base.infrastructure.persistence.entity.aifactory.Pipeline; @@ -30,9 +30,9 @@ 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.persistence.entity.tms.TmsTestCase; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; @@ -51,7 +51,7 @@ import org.springframework.transaction.TransactionStatus; @ExtendWith(MockitoExtension.class) -class TmsTestCasePushToAgentServiceTest { +class TmsTestCaseFixRoundStartServiceTest { private static final long PROJECT_ID = 1L; private static final Long TEST_CASE_ID = 103L; @@ -63,7 +63,7 @@ class TmsTestCasePushToAgentServiceTest { @Mock private TmsTestCaseReviewCommentRepository commentRepository; @Mock - private TmsTestCaseFixPushRepository fixPushRepository; + private TmsTestCaseFixRoundRepository fixRoundRepository; @Mock private PipelineStageTestCaseRepository stageTestCaseRepository; @Mock @@ -77,7 +77,7 @@ class TmsTestCasePushToAgentServiceTest { @Mock private CiTriggerConnector githubConnector; - private TmsTestCasePushToAgentService sut; + private TmsTestCaseFixRoundStartService sut; private TmsTestCase aiCase; private TmsTestCaseReviewComment unsent; @@ -87,7 +87,7 @@ class TmsTestCasePushToAgentServiceTest { void setUp() { lenient().when(transactionManager.getTransaction(any())).thenReturn(mock(TransactionStatus.class)); when(githubConnector.getSupportedExecutorType()).thenReturn("GITHUB_ACTIONS"); - sut = new TmsTestCasePushToAgentService(testCaseRepository, commentRepository, fixPushRepository, + sut = new TmsTestCaseFixRoundStartService(testCaseRepository, commentRepository, fixRoundRepository, stageTestCaseRepository, iterationRepository, stageRepository, integrationRepository, List.of(githubConnector), transactionManager); @@ -131,153 +131,153 @@ private void givenTheCaseHasUnsentComments() { when(commentRepository.findByTestCaseIdAndState(TEST_CASE_ID, TmsReviewCommentState.NOT_SENT)).thenReturn(List.of(unsent)); } - private void givenPushIsSaved() { - when(fixPushRepository.findLastRoundNumber(TEST_CASE_ID)).thenReturn(0); - when(fixPushRepository.saveAndFlush(any(TmsTestCaseFixPush.class))).thenAnswer(call -> { - TmsTestCaseFixPush saved = call.getArgument(0); + private void givenTheRoundIsSaved() { + when(fixRoundRepository.findLastRoundNumber(TEST_CASE_ID)).thenReturn(0); + when(fixRoundRepository.saveAndFlush(any(TmsTestCaseFixRound.class))).thenAnswer(call -> { + TmsTestCaseFixRound saved = call.getArgument(0); saved.setId(7L); return saved; }); } @Test - void push_ShouldRecordRoundOneStartTheJobWithOnlyIdsAndMarkTheCommentsAsSent() { + void start_ShouldRecordRoundOneStartTheJobWithOnlyIdsAndMarkTheCommentsAsSent() { givenTheCaseHasUnsentComments(); givenAnIterationWithCiAndAnIntegration(); - givenPushIsSaved(); - var saved = new TmsTestCaseFixPush(); + givenTheRoundIsSaved(); + var saved = new TmsTestCaseFixRound(); saved.setId(7L); saved.setTestCaseId(TEST_CASE_ID); saved.setRoundNumber(1); - saved.setStatus(TmsFixPushStatus.PENDING); - when(fixPushRepository.findById(7L)).thenReturn(Optional.of(saved)); + saved.setStatus(TmsFixRoundStatus.PENDING); + when(fixRoundRepository.findById(7L)).thenReturn(Optional.of(saved)); when(githubConnector.triggerRun(any(), any())).thenReturn("https://github.com/org/repo/actions/workflows/fix.yml"); - when(commentRepository.findByFixPushId(7L)).thenReturn(List.of(unsent)); + when(commentRepository.findByFixRoundId(7L)).thenReturn(List.of(unsent)); - var round = sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID); + var round = sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID); var request = ArgumentCaptor.forClass(CiRunRequest.class); verify(githubConnector).triggerRun(any(), request.capture()); assertEquals("org/repo", request.getValue().repo()); assertEquals("ai-pipeline.yml", request.getValue().workflowRef()); - assertEquals(Map.of("test_case_id", "103", "push_id", "7", "previous_iteration_id", "41"), request.getValue().inputs()); + assertEquals(Map.of("test_case_id", "103", "fix_round_id", "7", "previous_iteration_id", "41"), request.getValue().inputs()); assertEquals(TmsReviewCommentState.SENT, unsent.getState()); - assertEquals(7L, unsent.getFixPushId()); + assertEquals(7L, unsent.getFixRoundId()); assertEquals(7L, round.getId()); assertEquals(1, round.getRoundNumber()); assertEquals("https://github.com/org/repo/actions/workflows/fix.yml", round.getWorkflowUrl()); } @Test - void push_ShouldNumberTheRoundAfterTheLastOneOfTheCase() { + void start_ShouldNumberTheRoundAfterTheLastOneOfTheCase() { givenTheCaseHasUnsentComments(); givenAnIterationWithCiAndAnIntegration(); - when(fixPushRepository.findLastRoundNumber(TEST_CASE_ID)).thenReturn(2); - when(fixPushRepository.saveAndFlush(any(TmsTestCaseFixPush.class))).thenAnswer(call -> { - TmsTestCaseFixPush saved = call.getArgument(0); + when(fixRoundRepository.findLastRoundNumber(TEST_CASE_ID)).thenReturn(2); + when(fixRoundRepository.saveAndFlush(any(TmsTestCaseFixRound.class))).thenAnswer(call -> { + TmsTestCaseFixRound saved = call.getArgument(0); saved.setId(9L); return saved; }); when(githubConnector.triggerRun(any(), any())).thenReturn("url"); - when(fixPushRepository.findById(9L)).thenAnswer(call -> Optional.of(new TmsTestCaseFixPush())); - when(commentRepository.findByFixPushId(9L)).thenReturn(List.of()); + when(fixRoundRepository.findById(9L)).thenAnswer(call -> Optional.of(new TmsTestCaseFixRound())); + when(commentRepository.findByFixRoundId(9L)).thenReturn(List.of()); - sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID); + sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID); - var push = ArgumentCaptor.forClass(TmsTestCaseFixPush.class); - verify(fixPushRepository).saveAndFlush(push.capture()); - assertEquals(3, push.getValue().getRoundNumber()); - assertEquals(TmsFixPushStatus.PENDING, push.getValue().getStatus()); - assertEquals(ITERATION_ID, push.getValue().getPreviousIterationId()); - assertEquals(USER_ID, push.getValue().getRequestedBy()); + var round = ArgumentCaptor.forClass(TmsTestCaseFixRound.class); + verify(fixRoundRepository).saveAndFlush(round.capture()); + assertEquals(3, round.getValue().getRoundNumber()); + assertEquals(TmsFixRoundStatus.PENDING, round.getValue().getStatus()); + assertEquals(ITERATION_ID, round.getValue().getPreviousIterationId()); + assertEquals(USER_ID, round.getValue().getRequestedBy()); } @Test - void push_WhenTheJobCannotBeStarted_ShouldRecordNoRoundAndKeepTheCommentsNotSent() { + void start_WhenTheJobCannotBeStarted_ShouldRecordNoRoundAndKeepTheCommentsNotSent() { givenTheCaseHasUnsentComments(); givenAnIterationWithCiAndAnIntegration(); - givenPushIsSaved(); + givenTheRoundIsSaved(); when(githubConnector.triggerRun(any(), any())).thenThrow(new ReportPortalException(ErrorType.BAD_REQUEST_ERROR, "CI is down")); - when(commentRepository.findByFixPushId(7L)).thenReturn(List.of(unsent)); + when(commentRepository.findByFixRoundId(7L)).thenReturn(List.of(unsent)); unsent.setState(TmsReviewCommentState.SENT); - unsent.setFixPushId(7L); + unsent.setFixRoundId(7L); - assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertEquals(TmsReviewCommentState.NOT_SENT, unsent.getState()); - assertNull(unsent.getFixPushId()); - verify(fixPushRepository).deleteById(7L); + assertNull(unsent.getFixRoundId()); + verify(fixRoundRepository).deleteById(7L); } @Test - void push_WithoutUnsentComments_ShouldBeRejectedBeforeTouchingCi() { + void start_WithoutUnsentComments_ShouldBeRejectedBeforeTouchingCi() { when(commentRepository.findByTestCaseIdAndState(TEST_CASE_ID, TmsReviewCommentState.NOT_SENT)).thenReturn(List.of()); - var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + var ex = assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); verify(githubConnector, never()).triggerRun(any(), any()); } @Test - void push_OfAManualCase_ShouldBeRejected() { + void start_OfAManualCase_ShouldBeRejected() { aiCase.setOrigin(TmsTestCaseOrigin.MANUAL); - var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + var ex = assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); } @Test - void push_WhileARoundIsRunning_ShouldBeAConflict() { - when(fixPushRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(true); + void start_WhileARoundIsRunning_ShouldBeAConflict() { + when(fixRoundRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixRoundStatus.PENDING)).thenReturn(true); - var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + var ex = assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertEquals(ErrorType.TEST_CASE_REVIEW_CONFLICT.getCode(), ex.getErrorType().getCode()); } @Test - void push_OfACaseNoIterationProduced_ShouldBeRejected() { + void start_OfACaseNoIterationProduced_ShouldBeRejected() { givenTheCaseHasUnsentComments(); when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(TEST_CASE_ID))).thenReturn(List.of()); - var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + var ex = assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); } @Test - void push_WhenNoStageTellsWhereTheIterationRan_ShouldBeRejectedAsNotConnectedToCi() { + void start_WhenNoStageTellsWhereTheIterationRan_ShouldBeRejectedAsNotConnectedToCi() { givenTheCaseHasUnsentComments(); when(stageTestCaseRepository.findIterationIdsByTestCaseIdIn(List.of(TEST_CASE_ID))).thenReturn(List.of(ITERATION_ID)); when(stageRepository.findByIterationIdOrderBySequenceAsc(ITERATION_ID)).thenReturn(List.of(new PipelineStage())); - var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + var ex = assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertTrue(ex.getMessage().contains("not connected to CI")); } @Test - void push_WithoutAnEnabledIntegration_ShouldBeRejected() { + void start_WithoutAnEnabledIntegration_ShouldBeRejected() { givenTheCaseHasUnsentComments(); givenAnIterationWithCiAndAnIntegration(); integration.setEnabled(false); - var ex = assertThrows(ReportPortalException.class, () -> sut.push(PROJECT_ID, TEST_CASE_ID, USER_ID)); + var ex = assertThrows(ReportPortalException.class, () -> sut.start(PROJECT_ID, TEST_CASE_ID, USER_ID)); assertEquals(ErrorType.BAD_REQUEST_ERROR.getCode(), ex.getErrorType().getCode()); - verify(fixPushRepository, never()).saveAndFlush(any()); + verify(fixRoundRepository, never()).saveAndFlush(any()); } @Test - void getPush_OfAnotherTestCase_ShouldBeNotFound() { - var push = new TmsTestCaseFixPush(); - push.setProjectId(PROJECT_ID); - push.setTestCaseId(999L); - when(fixPushRepository.findById(7L)).thenReturn(Optional.of(push)); + void getRound_OfAnotherTestCase_ShouldBeNotFound() { + var round = new TmsTestCaseFixRound(); + round.setProjectId(PROJECT_ID); + round.setTestCaseId(999L); + when(fixRoundRepository.findById(7L)).thenReturn(Optional.of(round)); - var ex = assertThrows(ReportPortalException.class, () -> sut.getPush(PROJECT_ID, TEST_CASE_ID, 7L)); + var ex = assertThrows(ReportPortalException.class, () -> sut.getRound(PROJECT_ID, TEST_CASE_ID, 7L)); assertEquals(ErrorType.NOT_FOUND.getCode(), ex.getErrorType().getCode()); } diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java index 8733efc285..784ae91ba8 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReadyEligibilityServiceTest.java @@ -8,9 +8,9 @@ import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.rules.exception.ErrorType; import com.epam.reportportal.base.infrastructure.rules.exception.ReportPortalException; @@ -28,7 +28,7 @@ class TmsTestCaseReadyEligibilityServiceTest { @Mock private TmsTestCaseReviewCommentRepository commentRepository; @Mock - private TmsTestCaseFixPushRepository fixPushRepository; + private TmsTestCaseFixRoundRepository fixRoundRepository; @InjectMocks private TmsTestCaseReadyEligibilityService sut; @@ -37,7 +37,7 @@ class TmsTestCaseReadyEligibilityServiceTest { void findCasesThatCannotBecomeReady_ShouldContainCasesWithUnsentCommentsAndCasesTheAgentIsFixing() { var ids = List.of(1L, 2L, 3L); when(commentRepository.findTestCaseIdsWithState(ids, TmsReviewCommentState.NOT_SENT)).thenReturn(List.of(1L)); - when(fixPushRepository.findTestCaseIdsWithStatus(ids, TmsFixPushStatus.PENDING)).thenReturn(List.of(2L)); + when(fixRoundRepository.findTestCaseIdsWithStatus(ids, TmsFixRoundStatus.PENDING)).thenReturn(List.of(2L)); assertEquals(Set.of(1L, 2L), sut.findCasesThatCannotBecomeReady(ids)); } @@ -51,7 +51,7 @@ void findCasesThatCannotBecomeReady_WithoutCases_ShouldBeEmptyWithoutAskingTheDa void canBecomeReady_WhenNothingBlocksTheCase_ShouldBeTrue() { lenient().when(commentRepository.findTestCaseIdsWithState(List.of(1L), TmsReviewCommentState.NOT_SENT)) .thenReturn(List.of()); - lenient().when(fixPushRepository.findTestCaseIdsWithStatus(List.of(1L), TmsFixPushStatus.PENDING)) + lenient().when(fixRoundRepository.findTestCaseIdsWithStatus(List.of(1L), TmsFixRoundStatus.PENDING)) .thenReturn(List.of()); assertTrue(sut.canBecomeReady(1L)); @@ -59,7 +59,7 @@ void canBecomeReady_WhenNothingBlocksTheCase_ShouldBeTrue() { @Test void assertCanBecomeReady_WhileTheAgentIsFixing_ShouldBeAConflict() { - when(fixPushRepository.existsByTestCaseIdAndStatus(1L, TmsFixPushStatus.PENDING)).thenReturn(true); + when(fixRoundRepository.existsByTestCaseIdAndStatus(1L, TmsFixRoundStatus.PENDING)).thenReturn(true); var ex = assertThrows(ReportPortalException.class, () -> sut.assertCanBecomeReady(1L)); diff --git a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java index d00aded4b2..418b2c4995 100644 --- a/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java +++ b/src/test/java/com/epam/reportportal/base/core/tms/service/TmsTestCaseReviewCommentServiceImplTest.java @@ -11,13 +11,13 @@ import static org.mockito.Mockito.when; import com.epam.reportportal.base.core.tms.dto.TmsReviewCommentRQ; -import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixPushRepository; +import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseFixRoundRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseRepository; import com.epam.reportportal.base.infrastructure.persistence.dao.tms.TmsTestCaseReviewCommentRepository; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCase; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixPush; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseFixRound; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.TmsTestCaseReviewComment; -import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixPushStatus; +import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsFixRoundStatus; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentState; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsReviewCommentTarget; import com.epam.reportportal.base.infrastructure.persistence.entity.tms.enums.TmsTestCaseOrigin; @@ -46,7 +46,7 @@ class TmsTestCaseReviewCommentServiceImplTest { @Mock private TmsTestCaseReviewCommentRepository commentRepository; @Mock - private TmsTestCaseFixPushRepository fixPushRepository; + private TmsTestCaseFixRoundRepository fixRoundRepository; @InjectMocks private TmsTestCaseReviewCommentServiceImpl sut; @@ -114,7 +114,7 @@ void addComment_OnAManualCase_ShouldBeRejected() { @Test void addComment_WhileTheAgentIsFixingTheCase_ShouldBeRejectedAsAConflict() { - when(fixPushRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(true); + when(fixRoundRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixRoundStatus.PENDING)).thenReturn(true); var ex = assertThrows(ReportPortalException.class, () -> sut.addComment(PROJECT_ID, TEST_CASE_ID, USER_ID, stepComment(1, "text"))); @@ -144,9 +144,9 @@ void getReview_ShouldCountTheUnsentCommentsAndTellWhetherTheAgentIsFixing() { comment(1L, USER_ID, TmsReviewCommentState.NOT_SENT), comment(2L, USER_ID, TmsReviewCommentState.NOT_SENT), comment(3L, USER_ID, TmsReviewCommentState.ADDRESSED))); - var push = new TmsTestCaseFixPush(); + var push = new TmsTestCaseFixRound(); push.setRoundNumber(2); - when(fixPushRepository.findByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)) + when(fixRoundRepository.findByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixRoundStatus.PENDING)) .thenReturn(Optional.of(push)); var review = sut.getReview(PROJECT_ID, TEST_CASE_ID); @@ -160,7 +160,7 @@ void getReview_ShouldCountTheUnsentCommentsAndTellWhetherTheAgentIsFixing() { @Test void getReview_WhenNothingIsRunning_ShouldNotReportAFixRound() { when(commentRepository.findByTestCaseIdOrderByCreatedAtAscIdAsc(TEST_CASE_ID)).thenReturn(List.of()); - when(fixPushRepository.findByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(Optional.empty()); + when(fixRoundRepository.findByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixRoundStatus.PENDING)).thenReturn(Optional.empty()); var review = sut.getReview(PROJECT_ID, TEST_CASE_ID); @@ -223,7 +223,7 @@ void discardUnsentComments_ShouldRemoveOnlyTheNotSentOnes() { @Test void discardUnsentComments_WhileTheAgentIsFixingTheCase_ShouldBeRejected() { - when(fixPushRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixPushStatus.PENDING)).thenReturn(true); + when(fixRoundRepository.existsByTestCaseIdAndStatus(TEST_CASE_ID, TmsFixRoundStatus.PENDING)).thenReturn(true); assertThrows(ReportPortalException.class, () -> sut.discardUnsentComments(PROJECT_ID, TEST_CASE_ID)); From e17dae6a9aec4363cd309907f54116b868482318 Mon Sep 17 00:00:00 2001 From: Hleb Mashkanov Date: Sun, 25 Oct 2026 09:57:35 +0100 Subject: [PATCH 12/12] Pipelines implementation --- .../controller/PipelineController.java | 21 +++++++++++- .../core/aifactory/dto/PipelineStageRS.java | 4 +++ .../aifactory/dto/PipelineStageSummaryRS.java | 4 +++ .../core/aifactory/mapper/PipelineMapper.java | 2 ++ .../aifactory/service/PipelineService.java | 15 +++++++- .../service/PipelineServiceImpl.java | 23 +++++++++---- .../dao/aifactory/PipelineSpecifications.java | 32 ++++++++++------- .../controller/PipelineControllerTest.java | 30 ++++++++++++++-- .../integration/PipelineIntegrationTest.java | 34 +++++++++++++++++++ .../aifactory/mapper/PipelineMapperTest.java | 3 ++ .../service/PipelineServiceImplTest.java | 24 +++++++++++-- 11 files changed, 167 insertions(+), 25 deletions(-) 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 index f2126cfb6c..69dbaa0d44 100644 --- 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 @@ -77,19 +77,38 @@ public class PipelineController { + "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 = "name", in = ParameterIn.QUERY, description = "Case-insensitive substring over the pipeline name " + + "only (the iterations are not looked at). Applied before paging, so totals and pages are exact; combined " + + "with search, both must match. Example: name=gener finds \"Test case generation\"") @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, + @RequestParam(required = false) String name, @PagingOffset OffsetRequest offsetRequest, @AuthenticationPrincipal ReportPortalUser user) { var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); - var page = pipelineService.getPipelines(projectId, search, offsetRequest); + var page = pipelineService.getPipelines(projectId, search, name, offsetRequest); return PagedResourcesAssembler.pageConverter().apply(page); } + @GetMapping("/{pipelineId}") + @ResponseStatus(HttpStatus.OK) + @PreAuthorize(ALLOWED_TO_VIEW_PROJECT) + @Operation(summary = "Get one pipeline definition with its settings", + description = "The same object as an item of the list, e.g. to read the current Auto-Ready settings of the " + + "pipeline of an iteration opened by a direct link. 404 when the pipeline is not in the project.") + public PipelineRS getPipeline( + @PathVariable String projectKey, + @PathVariable Long pipelineId, + @AuthenticationPrincipal ReportPortalUser user) { + + var projectId = projectExtractor.extractMembershipDetails(user, EntityUtils.normalizeId(projectKey)).getProjectId(); + return pipelineService.getPipeline(projectId, pipelineId); + } + @PatchMapping("/{pipelineId}") @ResponseStatus(HttpStatus.OK) @PreAuthorize(ALLOWED_TO_EDIT_PROJECT) 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 index 67756d7aeb..06f525f3d6 100644 --- 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 @@ -23,6 +23,10 @@ public class PipelineStageRS { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Key the pipeline reported the stage under, e.g. " + + "\"create\", \"grade\" or \"upload\"; unlike the name and the sequence it is what identifies the same stage in " + + "every iteration, so the UI picks a specialized panel by it") + private String stageKey; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String name; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) 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 index 3752629f6c..741f47e519 100644 --- 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 @@ -23,6 +23,10 @@ public class PipelineStageSummaryRS { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private Long id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Key the pipeline reported the stage under, e.g. " + + "\"create\", \"grade\" or \"upload\"; unlike the name and the sequence it is what identifies the same stage in " + + "every iteration, so the UI picks a specialized panel by it") + private String stageKey; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private String name; @Schema(requiredMode = Schema.RequiredMode.REQUIRED) 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 index 45bcfaef80..a807ce93cb 100644 --- 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 @@ -108,6 +108,7 @@ public PipelineUserRS toUserRS(User user) { public PipelineStageSummaryRS toStageSummaryRS(PipelineStage stage) { return PipelineStageSummaryRS.builder() .id(stage.getId()) + .stageKey(stage.getStageKey()) .name(stage.getName()) .sequence(stage.getSequence()) .attempt(stage.getAttempt()) @@ -128,6 +129,7 @@ public PipelineStageSummaryRS toStageSummaryRS(PipelineStage stage) { public PipelineStageRS toStageRS(PipelineStage stage) { return PipelineStageRS.builder() .id(stage.getId()) + .stageKey(stage.getStageKey()) .name(stage.getName()) .sequence(stage.getSequence()) .attempt(stage.getAttempt()) 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 index c3276c0361..0413cdcc6c 100644 --- 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 @@ -7,7 +7,20 @@ public interface PipelineService { - Page getPipelines(Long projectId, String search, OffsetRequest offsetRequest); + /** + * Pipelines of a project, ordered by name and id. {@code search} looks at the name and at the attributes of the + * iterations; {@code name} looks at the pipeline name only. Both narrow the result before it is cut into pages. + *

+ * Example: {@code name = "gener"} returns "Test case generation" and no pipeline that merely has an iteration + * about a "generated" requirement. + */ + Page getPipelines(Long projectId, String search, String name, OffsetRequest offsetRequest); + + /** + * One pipeline of a project with its settings, e.g. to show the current Auto-Ready threshold when the page was + * opened from a link to an iteration and the list of pipelines was never loaded. + */ + PipelineRS getPipeline(Long projectId, Long pipelineId); /** * Changes the settings of a pipeline definition. Each setting named in the request replaces the stored one, 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 index e03566e000..03307bdf02 100644 --- 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 @@ -34,8 +34,8 @@ public class PipelineServiceImpl implements PipelineService { @Override @Transactional(readOnly = true) - public Page getPipelines(Long projectId, String search, OffsetRequest offsetRequest) { - var page = pipelineRepository.findAll(PipelineSpecifications.pipelinesOfProject(projectId, search), + public Page getPipelines(Long projectId, String search, String name, OffsetRequest offsetRequest) { + var page = pipelineRepository.findAll(PipelineSpecifications.pipelinesOfProject(projectId, search, name), PipelinePaging.capLimit(offsetRequest, Sort.by("name", "id"))); var pipelineIds = page .getContent() @@ -57,14 +57,15 @@ public Page getPipelines(Long projectId, String search, OffsetReques iterationsCountByPipelineId.getOrDefault(pipeline.getId(), 0L))); } + @Override + public PipelineRS getPipeline(Long projectId, Long pipelineId) { + return toPipelineRS(findPipelineOrThrow(projectId, pipelineId)); + } + @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 pipeline = findPipelineOrThrow(projectId, pipelineId); var settings = new LinkedHashMap( pipeline.getSettings() == null ? Map.of() : pipeline.getSettings().getValues()); @@ -81,6 +82,14 @@ public PipelineRS patchPipelineSettings(Long projectId, Long pipelineId, Pipelin return toPipelineRS(pipelineRepository.save(pipeline)); } + private Pipeline findPipelineOrThrow(Long projectId, Long pipelineId) { + return pipelineRepository + .findById(pipelineId) + .filter(p -> p.getProject().getId().equals(projectId)) + .orElseThrow( + () -> new ReportPortalException(ErrorType.NOT_FOUND, "Pipeline '" + pipelineId + "'")); + } + 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/infrastructure/persistence/dao/aifactory/PipelineSpecifications.java b/src/main/java/com/epam/reportportal/base/infrastructure/persistence/dao/aifactory/PipelineSpecifications.java index bd6ef473b3..6e4d5df038 100644 --- 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 @@ -29,22 +29,30 @@ 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. + * Pipelines of a project that match both given criteria; a blank criterion matches every pipeline. + *

    + *
  • {@code search}: by name, or because one of their iterations matches (see {@link #iterationMatches});
  • + *
  • {@code name}: by name only, as a case-insensitive substring. Example: {@code "gener"} finds + * "Test case generation" and not a pipeline whose iteration only mentions "generated".
  • + *
*/ - public static Specification pipelinesOfProject(Long projectId, String search) { + public static Specification pipelinesOfProject(Long projectId, String search, String name) { return (root, query, cb) -> { - var inProject = cb.equal(root.get("project").get("id"), projectId); + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("project").get("id"), projectId)); + var nameTerm = normalize(name); + if (nameTerm != null) { + predicates.add(contains(cb, root.get("name"), nameTerm)); + } var term = normalize(search); - if (term == null) { - return inProject; + if (term != null) { + 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)); + predicates.add(cb.or(contains(cb, root.get("name"), term), cb.exists(iterations))); } - 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))); + return cb.and(predicates.toArray(Predicate[]::new)); }; } 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 index 7e69ad70d3..cfc481c39e 100644 --- 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 @@ -121,7 +121,7 @@ public Object resolveArgument(MethodParameter parameter, @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); + given(pipelineService.getPipelines(eq(projectId), isNull(), isNull(), any(OffsetRequest.class))).willReturn(page); mockMvc.perform(get("/v1/project/{projectKey}/pipeline", projectKey)) .andExpect(status().isOk()) @@ -129,7 +129,33 @@ void getPipelines_ShouldDelegateToServiceAndReturnPagedContent() throws Exceptio .andExpect(jsonPath("$.content[0].name").value("factory-tc-gen")); verify(projectExtractor).extractMembershipDetails(eq(testUser), anyString()); - verify(pipelineService).getPipelines(eq(projectId), isNull(), any(OffsetRequest.class)); + verify(pipelineService).getPipelines(eq(projectId), isNull(), isNull(), any(OffsetRequest.class)); + } + + @Test + void getPipelines_WithNameParameter_ShouldPassItToTheServiceApartFromSearch() throws Exception { + given(pipelineService.getPipelines(eq(projectId), eq("EXP-002"), eq("gener"), any(OffsetRequest.class))) + .willReturn(new PageImpl(List.of())); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline", projectKey) + .param("search", "EXP-002") + .param("name", "gener")) + .andExpect(status().isOk()); + + verify(pipelineService).getPipelines(eq(projectId), eq("EXP-002"), eq("gener"), any(OffsetRequest.class)); + } + + @Test + void getPipeline_ShouldDelegateToServiceAndReturnTheSettings() throws Exception { + given(pipelineService.getPipeline(projectId, 2L)).willReturn(PipelineRS.builder().id(2L).name("factory-tc-gen") + .settings(java.util.Map.of("autoReady", java.util.Map.of("enabled", true, "threshold", 90))).build()); + + mockMvc.perform(get("/v1/project/{projectKey}/pipeline/{pipelineId}", projectKey, 2L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(2)) + .andExpect(jsonPath("$.settings.autoReady.threshold").value(90)); + + verify(pipelineService).getPipeline(projectId, 2L); } @Test 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 index 1a57db154c..ac5aef08bc 100644 --- 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 @@ -211,6 +211,39 @@ void getPipelines_WithSearch_ShouldMatchPipelineNameOrAnyOfItsIterations() throw .andExpect(jsonPath("$.content.length()").value(0)); } + @Test + void getPipelines_WithName_ShouldMatchThePipelineNameOnlyAndKeepTheTotalsExact() throws Exception { + mockMvc.perform(get(BASE).param("name", "TC-gen").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("factory-tc-gen")) + .andExpect(jsonPath("$.page.totalElements").value(1)); + // an iteration of the pipeline carries EXP-002, but the name does not: only the broad search finds it + mockMvc.perform(get(BASE).param("name", "EXP-002").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(0)) + .andExpect(jsonPath("$.page.totalElements").value(0)); + // both given: both must hold + mockMvc.perform(get(BASE).param("name", "tc-gen").param("search", "no-such-thing") + .with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.page.totalElements").value(0)); + // a blank name is no filter; % is a character, not a wildcard + mockMvc.perform(get(BASE).param("name", " ").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.content.length()").value(2)); + mockMvc.perform(get(BASE).param("name", "%").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(jsonPath("$.page.totalElements").value(0)); + } + + @Test + void getPipeline_ShouldReturnOneOfTheProjectsPipelinesAndNotFoundForAnotherProjects() throws Exception { + mockMvc.perform(get(BASE + "/900").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(900)) + .andExpect(jsonPath("$.name").value("factory-tc-gen")) + .andExpect(jsonPath("$.iterationsCount").value(2)); + mockMvc.perform(get(BASE + "/901").with(token(oAuthHelper.getSuperadminToken()))) + .andExpect(status().isNotFound()); + } + // ------------------------------------------------------------------ reporting @Test @@ -500,6 +533,7 @@ void getIterationDetail_ShouldReturnStagesWithSectionsAsReported() throws Except .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(900)) .andExpect(jsonPath("$.pipelineType").value("TEST_CASE_GENERATION")) + .andExpect(jsonPath("$.stages[0].stageKey").value("stage-gen-tc")) .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")) 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 index c983d001d9..8c6b6bc103 100644 --- 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 @@ -153,6 +153,7 @@ void toUserRS_ShouldMapLoginAndFullName() { void toStageSummaryRS_ShouldMapStatusAsReportedAndAttributesButNotHeavyDetails() { var stage = new PipelineStage(); stage.setId(50L); + stage.setStageKey("grade"); stage.setName("Grade"); stage.setSequence(2); stage.setParentStageId(40L); @@ -166,6 +167,7 @@ void toStageSummaryRS_ShouldMapStatusAsReportedAndAttributesButNotHeavyDetails() var result = mapper.toStageSummaryRS(stage); assertEquals(50L, result.getId()); + assertEquals("grade", result.getStageKey()); assertEquals("Grade", result.getName()); assertEquals(2, result.getSequence()); assertEquals(40L, result.getParentStageId()); @@ -301,6 +303,7 @@ void toStageRS_WithoutCiOrResult_ShouldLeaveThemNull() { var result = mapper.toStageRS(stage); assertEquals(50L, result.getId()); + assertEquals("stage-gen-tc", result.getStageKey()); assertEquals("In progress", result.getStatus()); assertEquals(StatusCategory.WAITING, result.getStatusCategory()); assertEquals(StageOwner.REPORTPORTAL, result.getOwnedBy()); 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 index 2ed3882775..533b11dd25 100644 --- 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 @@ -165,7 +165,7 @@ void listPipelines_ShouldBatchIterationCountsAcrossThePage() { .thenReturn(List.of(iterationCount(pipelineId, 3L))); when(pipelineMapper.toPipelineRS(pipeline, 3L)).thenReturn(expectedRS); - var result = sut.getPipelines(projectId, null, offsetRequest); + var result = sut.getPipelines(projectId, null, null, offsetRequest); assertEquals(List.of(expectedRS), result.getContent()); verify(pipelineIterationRepository, never()).countByPipelineId(any()); @@ -180,11 +180,31 @@ void listPipelines_WithoutIterations_ShouldStillReturnThePipelineWithZeroCount() 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)); + var result = sut.getPipelines(projectId, null, null, new OffsetRequest(0, 50)); assertEquals(List.of(expectedRS), result.getContent()); } + @Test + void getPipeline_ShouldReturnThePipelineWithItsIterationsCount() { + var expectedRS = PipelineRS.builder().id(pipelineId).build(); + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); + when(pipelineIterationRepository.countByPipelineId(pipelineId)).thenReturn(2L); + when(pipelineMapper.toPipelineRS(pipeline, 2L)).thenReturn(expectedRS); + + var result = sut.getPipeline(projectId, pipelineId); + + assertEquals(expectedRS, result); + verify(pipelineRepository, never()).save(any()); + } + + @Test + void getPipeline_OfAnotherProject_ShouldBeNotFound() { + when(pipelineRepository.findById(pipelineId)).thenReturn(Optional.of(pipeline)); + + assertThrows(ReportPortalException.class, () -> sut.getPipeline(projectId + 1, pipelineId)); + } + private PipelineIterationRepository.PipelineIterationCount iterationCount(Long pipelineId, Long count) { return new PipelineIterationRepository.PipelineIterationCount() { @Override