diff --git a/ai-data-processor/pom.xml b/ai-data-processor/pom.xml index 28b404c41..2f377276c 100644 --- a/ai-data-processor/pom.xml +++ b/ai-data-processor/pom.xml @@ -144,6 +144,11 @@ junit-jupiter-api test + + com.knowhow.retro + ai-gateway-client + 1.0.0 + diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/AiDataProcessorApplication.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/AiDataProcessorApplication.java index 7e2ddcdb4..26a356ef5 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/AiDataProcessorApplication.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/AiDataProcessorApplication.java @@ -9,8 +9,9 @@ import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@ComponentScan(basePackages = {"com.publicissapient", "com.knowhow.retro.notifications"}) -@EnableMongoRepositories(basePackages = {"com.publicissapient.**.repository"}) +@ComponentScan(basePackages = { "com.publicissapient", "com.knowhow.retro.notifications", + "com.knowhow.retro.aigatewayclient" }) +@EnableMongoRepositories(basePackages = { "com.publicissapient.**.repository" }) @EnableBatchProcessing @EnableAsync @EnableScheduling diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/KnowHOWClient.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/KnowHOWClient.java index 1d66395dc..cc84cc04d 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/KnowHOWClient.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/KnowHOWClient.java @@ -74,6 +74,22 @@ public List getKpiIntegrationValues(List kpiRequests) { }).flatMapIterable(list -> list).collectList().block(); } + public List getKpiIntegrationValuesKanban(List kpiRequests) { + return Flux.fromIterable(kpiRequests).publishOn(Schedulers.boundedElastic()).flatMap(kpiRequest -> { + try { + semaphore.acquire(); + return this.knowHOWWebClient.post() + .uri(this.knowHOWApiClientConfig.getKpiIntegrationValuesKanbanEndpointConfig().getPath()) + .bodyValue(kpiRequest).retrieve().bodyToFlux(KpiElement.class).retryWhen(retrySpec()) + .collectList().doFinally(signalType -> semaphore.release()); + } catch (InterruptedException e) { + log.error("Could not get kpi integration values kanban for kpiRequest {}", kpiRequest); + Thread.currentThread().interrupt(); + return Flux.error(e); + } + }).flatMapIterable(list -> list).collectList().block(); + } + private RetryBackoffSpec retrySpec() { return Retry .backoff(knowHOWApiClientConfig.getRetryPolicy().getMaxAttempts(), diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/config/KnowHOWApiClientConfig.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/config/KnowHOWApiClientConfig.java index f92fcdb50..81e8a418a 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/config/KnowHOWApiClientConfig.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/customapi/config/KnowHOWApiClientConfig.java @@ -50,4 +50,8 @@ public static class EndpointConfig { public EndpointConfig getKpiIntegrationValuesEndpointConfig() { return this.endpoints.get("kpi-integration-values"); } + + public EndpointConfig getKpiIntegrationValuesKanbanEndpointConfig() { + return this.endpoints.get("kpi-integration-values-kanban"); + } } \ No newline at end of file diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/shareddataservice/SharedDataServiceClient.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/shareddataservice/SharedDataServiceClient.java index 49bc51b93..4bad2bc7b 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/shareddataservice/SharedDataServiceClient.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/client/shareddataservice/SharedDataServiceClient.java @@ -17,15 +17,20 @@ package com.publicissapient.kpidashboard.client.shareddataservice; import com.publicissapient.kpidashboard.client.shareddataservice.config.SharedDataServiceConfig; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.util.retry.Retry; +import reactor.util.retry.RetryBackoffSpec; +import java.net.ConnectException; import java.time.Duration; +@Slf4j @Component public class SharedDataServiceClient { private final WebClient webClient; @@ -47,9 +52,15 @@ public SharedDataServiceClient(SharedDataServiceConfig sharedDataServiceConfig) .build(); } - public PagedAIUsagePerOrgLevel getAIUsageStatsAsync(String levelName) { - int maxAttempts = sharedDataServiceConfig.getRetryPolicy().getMaxAttempts(); - int minBackoffDuration = sharedDataServiceConfig.getRetryPolicy().getMinBackoffDuration(); + public AIUsagePerOrgLevel getAIUsageStatsAsync(String levelName) { + RetryBackoffSpec retrySpec = Retry.backoff( + sharedDataServiceConfig.getRetryPolicy().getMaxAttempts(), + Duration.of(sharedDataServiceConfig.getRetryPolicy().getMinBackoffDuration(), + sharedDataServiceConfig.getRetryPolicy().getMinBackoffTimeUnit().toChronoUnit())) + .filter(SharedDataServiceClient::shouldRetry) + .doBeforeRetry(retrySignal -> + log.info("Retry #{} due to {}", retrySignal.totalRetries(), retrySignal.failure().toString())); + String path = sharedDataServiceConfig.getAiUsageStatisticsEndpoint().getPath(); return webClient.get() @@ -58,8 +69,15 @@ public PagedAIUsagePerOrgLevel getAIUsageStatsAsync(String levelName) { .queryParam(LEVEL_NAME_PARAM, levelName) .build()) .retrieve() - .bodyToMono(PagedAIUsagePerOrgLevel.class) - .retryWhen(Retry.backoff(maxAttempts, Duration.ofSeconds(minBackoffDuration)).jitter(0.5)) + .bodyToMono(AIUsagePerOrgLevel.class) + .retryWhen(retrySpec) .block(); } + + private static boolean shouldRetry(Throwable throwable) { + if (throwable instanceof WebClientResponseException ex) { + return ex.getStatusCode().is5xxServerError(); + } + return throwable instanceof ConnectException; + } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/PagedAIUsagePerOrgLevel.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/AIUsagePerOrgLevel.java similarity index 58% rename from ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/PagedAIUsagePerOrgLevel.java rename to ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/AIUsagePerOrgLevel.java index 44f59b38c..f8a27e149 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/PagedAIUsagePerOrgLevel.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/AIUsagePerOrgLevel.java @@ -19,13 +19,13 @@ import java.time.Instant; import java.util.List; -public record PagedAIUsagePerOrgLevel(String levelType, - String levelName, - Instant statsDate, - AIUsageSummary usageSummary, - List users, - int currentPage, - int totalPages, - long totalElements, - int pageSize) { +public record AIUsagePerOrgLevel(String levelType, + String levelName, + Instant statsDate, + AIUsageSummary usageSummary, + List users, + int currentPage, + int totalPages, + long totalElements, + int pageSize) { } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/mapper/AIUsageStatisticsMapper.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/mapper/AIUsageStatisticsMapper.java index f01ef1b20..24292523d 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/mapper/AIUsageStatisticsMapper.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/dto/mapper/AIUsageStatisticsMapper.java @@ -16,7 +16,7 @@ package com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.mapper; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.model.AIUsageStatistics; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -28,5 +28,5 @@ public interface AIUsageStatisticsMapper { @Mapping(target = "users", ignore = true) @Mapping(target = "ingestTimestamp", expression = "java(java.time.Instant.now())") - AIUsageStatistics toEntity(PagedAIUsagePerOrgLevel pagedAIUsagePerOrgLevel); + AIUsageStatistics toEntity(AIUsagePerOrgLevel aIUsagePerOrgLevel); } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/listener/AIUsageStatisticsJobCompletionListener.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/listener/AIUsageStatisticsJobCompletionListener.java index e17165258..c9c125c04 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/listener/AIUsageStatisticsJobCompletionListener.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/listener/AIUsageStatisticsJobCompletionListener.java @@ -16,9 +16,9 @@ package com.publicissapient.kpidashboard.job.aiusagestatisticscollector.listener; -import com.publicissapient.kpidashboard.common.model.ProcessorExecutionTraceLog; +import com.publicissapient.kpidashboard.common.model.tracelog.JobExecutionTraceLog; import com.publicissapient.kpidashboard.common.model.application.ErrorDetail; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.service.AccountBatchService; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,7 +37,7 @@ @AllArgsConstructor public class AIUsageStatisticsJobCompletionListener implements JobExecutionListener { private final AccountBatchService accountBatchService; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; @Override public void afterJob(@NonNull JobExecution jobExecution) { @@ -50,12 +50,12 @@ private void storeJobExecutionStatus(JobExecution jobExecution) { String jobName = jobParameters.getString("jobName"); ObjectId executionId = (ObjectId) Objects.requireNonNull(jobParameters.getParameter("executionId")).getValue(); - Optional processorExecutionTraceLogOptional = this.processorExecutionTraceLogServiceImpl + Optional executionTraceLogOptional = this.jobExecutionTraceLogService .findById(executionId); - if (processorExecutionTraceLogOptional.isPresent()) { - ProcessorExecutionTraceLog executionTraceLog = processorExecutionTraceLogOptional.get(); + if (executionTraceLogOptional.isPresent()) { + JobExecutionTraceLog executionTraceLog = executionTraceLogOptional.get(); executionTraceLog.setExecutionOngoing(false); - executionTraceLog.setExecutionEndedAt(Instant.now().toEpochMilli()); + executionTraceLog.setExecutionEndedAt(Instant.now()); executionTraceLog.setExecutionSuccess(jobExecution.getStatus() == BatchStatus.COMPLETED); executionTraceLog .setErrorDetailList(jobExecution.getAllFailureExceptions().stream().map(failureException -> { @@ -63,7 +63,7 @@ private void storeJobExecutionStatus(JobExecution jobExecution) { errorDetail.setError(failureException.getMessage()); return errorDetail; }).toList()); - this.processorExecutionTraceLogServiceImpl.saveAiDataProcessorExecutions(executionTraceLog); + this.jobExecutionTraceLogService.updateJobExecution(executionTraceLog); } else { log.error("Could not store job execution ending status for job with name {} and execution id {}. Job " + "execution could not be found", jobName, executionId); diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/processor/AccountItemProcessor.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/processor/AccountItemProcessor.java index 96c791975..1120d8fe1 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/processor/AccountItemProcessor.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/processor/AccountItemProcessor.java @@ -18,9 +18,10 @@ import org.springframework.batch.item.ItemProcessor; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.model.AIUsageStatistics; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.service.AIUsageStatisticsService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import jakarta.annotation.Nonnull; import lombok.AllArgsConstructor; @@ -28,12 +29,17 @@ @Slf4j @AllArgsConstructor -public class AccountItemProcessor implements ItemProcessor { +public class AccountItemProcessor implements ItemProcessor { private final AIUsageStatisticsService aiUsageStatisticsService; @Override - public AIUsageStatistics process(@Nonnull PagedAIUsagePerOrgLevel item) { - log.debug("[ai-usage-statistics-collector job] Fetching AI usage statistics for level name: {}", item.levelName()); - return aiUsageStatisticsService.fetchAIUsageStatistics(item.levelName()); + public AIUsageStatistics process(@Nonnull AIUsagePerOrgLevel item) { + log.debug("{} Fetching AI usage statistics for level name: {}", JobConstants.LOG_PREFIX_AI_USAGE_STATISTICS, item.levelName()); + try { + return aiUsageStatisticsService.fetchAIUsageStatistics(item.levelName()); + } catch (Exception ex) { + log.error("Failed fetching AI stats for {} – skipping", item.levelName()); + throw ex; + } } } \ No newline at end of file diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/reader/AccountItemReader.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/reader/AccountItemReader.java index 174f66c82..96a6fbbaf 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/reader/AccountItemReader.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/reader/AccountItemReader.java @@ -16,9 +16,10 @@ package com.publicissapient.kpidashboard.job.aiusagestatisticscollector.reader; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import org.springframework.batch.item.ItemReader; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.service.AccountBatchService; import lombok.RequiredArgsConstructor; @@ -26,14 +27,18 @@ @Slf4j @RequiredArgsConstructor -public class AccountItemReader implements ItemReader { +public class AccountItemReader implements ItemReader { private final AccountBatchService accountBatchService; @Override - public PagedAIUsagePerOrgLevel read() { - PagedAIUsagePerOrgLevel aiUsageStatistics = accountBatchService.getNextAccountPage(); - log.info("[ai-usage-statistics-collector job] Reader fetched level name: {}", aiUsageStatistics.levelName()); + public AIUsagePerOrgLevel read() { + AIUsagePerOrgLevel aiUsageStatistics = accountBatchService.getNextAccount(); + if (aiUsageStatistics == null) { + log.info("No more accounts."); + return null; + } + log.info("{} Reader fetched level name: {}", JobConstants.LOG_PREFIX_AI_USAGE_STATISTICS, aiUsageStatistics.levelName()); return aiUsageStatistics; } } \ No newline at end of file diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AIUsageStatisticsService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AIUsageStatisticsService.java index 40b782bbf..4940145dc 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AIUsageStatisticsService.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AIUsageStatisticsService.java @@ -20,7 +20,7 @@ import com.publicissapient.kpidashboard.client.shareddataservice.SharedDataServiceClient; import com.publicissapient.kpidashboard.exception.InternalServerErrorException; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.model.AIUsageStatistics; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.repository.AIUsageStatisticsRepository; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.mapper.AIUsageStatisticsMapper; @@ -40,7 +40,7 @@ public class AIUsageStatisticsService { public AIUsageStatistics fetchAIUsageStatistics(String levelName) { try { - PagedAIUsagePerOrgLevel aiUsageStatistics = sharedDataServiceClient.getAIUsageStatsAsync(levelName); + AIUsagePerOrgLevel aiUsageStatistics = sharedDataServiceClient.getAIUsageStatsAsync(levelName); return aiUsageStatisticsMapper.toEntity(aiUsageStatistics); } catch (Exception ex) { log.error("Failed to fetch AI usage stats for {}: {}", levelName, ex.getMessage()); @@ -51,6 +51,6 @@ public AIUsageStatistics fetchAIUsageStatistics(String levelName) { @Transactional public void saveAll(List aiUsageStatisticsList) { aiUsageStatisticsRepository.saveAll(aiUsageStatisticsList); - log.info("Successfully fetched and saved {} AI usage statistics", aiUsageStatisticsList.size()); + log.info("Successfully fetched and saved {} AI usage statistics for account: {}", aiUsageStatisticsList.size(), aiUsageStatisticsList.get(0).getLevelName()); } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AccountBatchService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AccountBatchService.java index 098f42480..b8365b182 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AccountBatchService.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/service/AccountBatchService.java @@ -18,7 +18,7 @@ import com.publicissapient.kpidashboard.common.model.application.AccountHierarchy; import com.publicissapient.kpidashboard.common.repository.application.AccountHierarchyRepository; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -62,10 +62,10 @@ public void initializeBatchProcessingParametersForTheNextProcess() { } /** - * Return next account to process as a PagedAIUsagePerOrgLevel. + * Return next account to process as a AIUsagePerOrgLevel. * Each page contains exactly 1 account because the endpoint supports only 1 account per request. */ - public PagedAIUsagePerOrgLevel getNextAccountPage() { + public AIUsagePerOrgLevel getNextAccount() { if (!initialized) { initializeBatchProcessingParametersForTheNextProcess(); } @@ -76,7 +76,7 @@ public PagedAIUsagePerOrgLevel getNextAccountPage() { AccountHierarchy account = allAccounts.get(currentIndex); - PagedAIUsagePerOrgLevel page = new PagedAIUsagePerOrgLevel( + AIUsagePerOrgLevel page = new AIUsagePerOrgLevel( "account", account.getNodeName(), Instant.now(), diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/strategy/AIUsageStatisticsJobStrategy.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/strategy/AIUsageStatisticsJobStrategy.java index eb73bdfd2..849753dca 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/strategy/AIUsageStatisticsJobStrategy.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/strategy/AIUsageStatisticsJobStrategy.java @@ -19,6 +19,7 @@ import java.util.Optional; import java.util.concurrent.Future; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import org.springframework.batch.core.Job; import org.springframework.batch.core.Step; import org.springframework.batch.core.job.builder.JobBuilder; @@ -30,9 +31,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.PlatformTransactionManager; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.config.AIUsageStatisticsCollectorJobConfig; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.listener.AIUsageStatisticsJobCompletionListener; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.model.AIUsageStatistics; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.processor.AccountItemProcessor; @@ -61,7 +62,7 @@ public class AIUsageStatisticsJobStrategy implements JobStrategy { private final AccountBatchService accountBatchService; private final AIUsageStatisticsService aiUsageStatisticsService; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; @Override public String getJobName() { @@ -72,7 +73,7 @@ public String getJobName() { public Job getJob() { Step startStep = chunkProcessAIUsageStatisticsForAccounts(); AIUsageStatisticsJobCompletionListener jobListener = new AIUsageStatisticsJobCompletionListener( - this.accountBatchService, this.processorExecutionTraceLogServiceImpl); + this.accountBatchService, this.jobExecutionTraceLogService); return new JobBuilder(aiUsageStatisticsCollectorJobConfig.getName(), jobRepository) .start(startStep) .listener(jobListener) @@ -85,18 +86,21 @@ public Optional getSchedulingConfig() { } private Step chunkProcessAIUsageStatisticsForAccounts() { - return new StepBuilder("process-ai-usage-statistics", jobRepository) - .>chunk( + .>chunk( aiUsageStatisticsCollectorJobConfig.getBatching().getChunkSize(), transactionManager) + .faultTolerant() + .skip(Exception.class) + .skipLimit(1000) + .noRetry(Exception.class) .reader(new AccountItemReader(accountBatchService)) .processor(asyncAccountProcessor()) .writer(asyncItemWriter()) .build(); } - private AsyncItemProcessor asyncAccountProcessor() { - AsyncItemProcessor asyncItemProcessor = new AsyncItemProcessor<>(); + private AsyncItemProcessor asyncAccountProcessor() { + AsyncItemProcessor asyncItemProcessor = new AsyncItemProcessor<>(); asyncItemProcessor.setDelegate(new AccountItemProcessor(this.aiUsageStatisticsService)); asyncItemProcessor.setTaskExecutor(taskExecutor); return asyncItemProcessor; diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/writer/AccountItemWriter.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/writer/AccountItemWriter.java index d3f6091b1..eb79b0286 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/writer/AccountItemWriter.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/aiusagestatisticscollector/writer/AccountItemWriter.java @@ -23,6 +23,7 @@ import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.model.AIUsageStatistics; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.service.AIUsageStatisticsService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import lombok.AllArgsConstructor; import lombok.NonNull; @@ -35,7 +36,7 @@ public class AccountItemWriter implements ItemWriter { @Override public void write(@NonNull Chunk chunk) { - log.info("[ai-usage-statistics-collector job] Received chunk items for inserting into database with size: {}", chunk.size()); + log.info("{} Received chunk items for inserting into database with size: {}", JobConstants.LOG_PREFIX_AI_USAGE_STATISTICS, chunk.size()); aiUsageStatisticsService.saveAll((List.copyOf(chunk.getItems()))); } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/constant/JobConstants.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/constant/JobConstants.java new file mode 100644 index 000000000..6fb954521 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/constant/JobConstants.java @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Sapient Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the + * License. + */ + +package com.publicissapient.kpidashboard.job.constant; + +import lombok.experimental.UtilityClass; + +/** + * Constants used across AI Data Processor jobs. + */ +@UtilityClass +public final class JobConstants { + + public static final String JOB_PRODUCTIVITY_CALCULATION = "productivity-calculation"; + public static final String JOB_KPI_MATURITY_CALCULATION = "kpi-maturity-calculation"; + public static final String JOB_AI_USAGE_STATISTICS_COLLECTOR = "ai-usage-statistics-collector"; + public static final String JOB_RECOMMENDATION_CALCULATION = "recommendation-calculation"; + + public static final String LOG_PREFIX_RECOMMENDATION = "[recommendation-calculation job]"; + public static final String LOG_PREFIX_PRODUCTIVITY = "[productivity-calculation job]"; + public static final String LOG_PREFIX_KPI_MATURITY = "[kpi-maturity-calculation job]"; + public static final String LOG_PREFIX_AI_USAGE_STATISTICS = "[ai-usage-statistics-collector job]"; + +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/listener/KpiMaturityCalculationJobExecutionListener.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/listener/KpiMaturityCalculationJobExecutionListener.java index da6c2ba23..7f7f5b8db 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/listener/KpiMaturityCalculationJobExecutionListener.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/listener/KpiMaturityCalculationJobExecutionListener.java @@ -27,9 +27,9 @@ import org.springframework.batch.core.JobParameters; import org.springframework.lang.NonNull; -import com.publicissapient.kpidashboard.common.model.ProcessorExecutionTraceLog; +import com.publicissapient.kpidashboard.common.model.tracelog.JobExecutionTraceLog; import com.publicissapient.kpidashboard.common.model.application.ErrorDetail; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; import com.publicissapient.kpidashboard.job.productivitycalculation.service.ProjectBatchService; import lombok.RequiredArgsConstructor; @@ -39,7 +39,7 @@ @RequiredArgsConstructor public class KpiMaturityCalculationJobExecutionListener implements JobExecutionListener { private final ProjectBatchService projectBatchService; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; @Override public void afterJob(@NonNull JobExecution jobExecution) { @@ -52,12 +52,12 @@ private void storeJobExecutionStatus(JobExecution jobExecution) { String jobName = jobParameters.getString("jobName"); ObjectId executionId = (ObjectId) Objects.requireNonNull(jobParameters.getParameter("executionId")).getValue(); - Optional processorExecutionTraceLogOptional = this.processorExecutionTraceLogServiceImpl + Optional executionTraceLogOptional = this.jobExecutionTraceLogService .findById(executionId); - if (processorExecutionTraceLogOptional.isPresent()) { - ProcessorExecutionTraceLog executionTraceLog = processorExecutionTraceLogOptional.get(); + if (executionTraceLogOptional.isPresent()) { + JobExecutionTraceLog executionTraceLog = executionTraceLogOptional.get(); executionTraceLog.setExecutionOngoing(false); - executionTraceLog.setExecutionEndedAt(Instant.now().toEpochMilli()); + executionTraceLog.setExecutionEndedAt(Instant.now()); executionTraceLog.setExecutionSuccess(jobExecution.getStatus() == BatchStatus.COMPLETED); executionTraceLog .setErrorDetailList(jobExecution.getAllFailureExceptions().stream().map(failureException -> { @@ -65,7 +65,7 @@ private void storeJobExecutionStatus(JobExecution jobExecution) { errorDetail.setError(failureException.getMessage()); return errorDetail; }).toList()); - this.processorExecutionTraceLogServiceImpl.saveAiDataProcessorExecutions(executionTraceLog); + this.jobExecutionTraceLogService.updateJobExecution(executionTraceLog); } else { log.error("Could not store job execution ending status for job with name {} and execution id {}. Job " + "execution could not be found", jobName, executionId); diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/processor/ProjectItemProcessor.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/processor/ProjectItemProcessor.java index c9d538452..58d98f699 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/processor/ProjectItemProcessor.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/processor/ProjectItemProcessor.java @@ -16,6 +16,7 @@ package com.publicissapient.kpidashboard.job.kpimaturitycalculation.processor; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import org.springframework.batch.item.ItemProcessor; import com.publicissapient.kpidashboard.common.model.kpimaturity.organization.KpiMaturity; @@ -34,7 +35,8 @@ public class ProjectItemProcessor implements ItemProcessor { public ProjectInputDTO read() { ProjectInputDTO projectInputDTO = projectBatchService.getNextProjectInputData(); - log.info("[kpi-maturity-calculation job] Received project input dto {}", projectInputDTO); + log.info("{} Received project input dto {}", JobConstants.LOG_PREFIX_KPI_MATURITY, projectInputDTO); return projectInputDTO; } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationService.java index 34edf9ee9..92424f296 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationService.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationService.java @@ -56,39 +56,50 @@ import lombok.extern.slf4j.Slf4j; /** - * Service responsible for calculating KPI maturity scores for projects within the KnowHOW platform. + * Service responsible for calculating KPI maturity scores for projects within + * the KnowHOW platform. * - *

This service performs comprehensive maturity assessment by: + *

+ * This service performs comprehensive maturity assessment by: *

    - *
  • Retrieving KPI data from various sources (Jira, Sonar, Jenkins, etc.)
  • - *
  • Calculating category-wise maturity scores based on configured weights
  • - *
  • Computing overall efficiency scores and percentages
  • - *
  • Persisting maturity data for organizational reporting
  • + *
  • Retrieving KPI data from various sources (Jira, Sonar, Jenkins, + * etc.)
  • + *
  • Calculating category-wise maturity scores based on configured + * weights
  • + *
  • Computing overall efficiency scores and percentages
  • + *
  • Persisting maturity data for organizational reporting
  • *
* - *

Key Business Logic: + *

+ * Key Business Logic: *

    - *
  • Maturity scores are calculated per category (quality, velocity, dora, etc.)
  • - *
  • Each category score is the average of its constituent KPI maturity values
  • - *
  • Efficiency score is a weighted sum of category scores with configurable weights
  • - *
  • Only KPIs with numeric maturity values are included in calculations
  • + *
  • Maturity scores are calculated per category (quality, velocity, dora, + * etc.)
  • + *
  • Each category score is the average of its constituent KPI maturity + * values
  • + *
  • Efficiency score is a weighted sum of category scores with configurable + * weights
  • + *
  • Only KPIs with numeric maturity values are included in calculations
  • *
* - *

Configuration Dependencies: + *

+ * Configuration Dependencies: *

    - *
  • KPI category mappings must be configured in the database
  • - *
  • Maturity calculation weights must be defined in application configuration
  • - *
  • Data point count for historical analysis is configurable
  • + *
  • KPI category mappings must be configured in the database
  • + *
  • Maturity calculation weights must be defined in application + * configuration
  • + *
  • Data point count for historical analysis is configurable
  • *
* - *

Data Flow: + *

+ * Data Flow: *

    - *
  1. Load eligible KPIs based on delivery methodology (SCRUM)
  2. - *
  3. Construct KPI requests based on granularity (Sprint/Week/Day/Month)
  4. - *
  5. Fetch KPI data from KnowHOW API
  6. - *
  7. Calculate category-wise maturity scores
  8. - *
  9. Compute weighted efficiency score
  10. - *
  11. Build and return KpiMaturity object
  12. + *
  13. Load eligible KPIs based on delivery methodology (SCRUM)
  14. + *
  15. Construct KPI requests based on granularity (Sprint/Week/Day/Month)
  16. + *
  17. Fetch KPI data from KnowHOW API
  18. + *
  19. Calculate category-wise maturity scores
  20. + *
  21. Compute weighted efficiency score
  22. + *
  23. Build and return KpiMaturity object
  24. *
*/ @Slf4j @@ -99,6 +110,8 @@ public class KpiMaturityCalculationService { /** Maximum possible efficiency score used for percentage calculations */ private static final double EFFICIENCY_MAX_SCORE = 5.0D; + private static final String KPI_GRANULARITY_WEEKS = "Weeks"; + private final KpiMaturityRepository kpiMaturityRepository; private final KpiCategoryMappingRepository kpiCategoryMappingRepository; private final KpiMasterCustomRepository kpiMasterCustomRepository; @@ -118,21 +131,23 @@ public void saveAll(List kpiMaturities) { this.kpiMaturityRepository.saveAll(kpiMaturities); } - /** * Calculates comprehensive KPI maturity assessment for a given project. * - *

This is the main entry point for maturity calculation. The method: + *

+ * This is the main entry point for maturity calculation. The method: *

    - *
  • Validates configuration settings
  • - *
  • Constructs appropriate KPI requests based on project structure
  • - *
  • Fetches KPI data from external sources
  • - *
  • Performs maturity calculations and scoring
  • + *
  • Validates configuration settings
  • + *
  • Constructs appropriate KPI requests based on project structure
  • + *
  • Fetches KPI data from external sources
  • + *
  • Performs maturity calculations and scoring
  • *
* - * @param projectInput the project data including organization hierarchy information and sprint details - * @return calculated KPI maturity object with scores and efficiency metrics, - * or {@code null} if insufficient data is available for calculation + * @param projectInput + * the project data including organization hierarchy information and + * sprint details + * @return calculated KPI maturity object with scores and efficiency metrics, or + * {@code null} if insufficient data is available for calculation */ public KpiMaturity calculateKpiMaturityForProject(ProjectInputDTO projectInput) { if (CollectionUtils @@ -142,36 +157,46 @@ public KpiMaturity calculateKpiMaturityForProject(ProjectInputDTO projectInput) kpiMaturityCalculationConfig.getCalculationConfig().getConfigValidationErrors()))); } List kpiRequests = constructKpiRequests(projectInput); - List kpiElementList = processAllKpiRequests(kpiRequests); + List kpiElementList = processAllKpiRequests(kpiRequests, projectInput.deliveryMethodology()); return calculateKpiMaturity(projectInput, kpiElementList); } - private List processAllKpiRequests(List kpiRequests) { + private List processAllKpiRequests(List kpiRequests, ProjectDeliveryMethodology projectDeliveryMethodology) { + if(projectDeliveryMethodology == ProjectDeliveryMethodology.KANBAN) { + return this.knowHOWClient.getKpiIntegrationValuesKanban(kpiRequests); + } return this.knowHOWClient.getKpiIntegrationValues(kpiRequests); } /** * Performs the core maturity calculation logic for a project. * - *

This method implements the business logic for: + *

+ * This method implements the business logic for: *

    - *
  • Validating that maturity calculation is possible
  • - *
  • Grouping KPIs by category and calculating category averages
  • - *
  • Computing weighted efficiency scores
  • - *
  • Building the final maturity assessment object
  • + *
  • Validating that maturity calculation is possible
  • + *
  • Grouping KPIs by category and calculating category averages
  • + *
  • Computing weighted efficiency scores
  • + *
  • Building the final maturity assessment object
  • *
* - *

Calculation Logic: + *

+ * Calculation Logic: *

    - *
  • Category scores = average of constituent KPI maturity values
  • - *
  • Efficiency score = weighted sum of category scores
  • - *
  • Efficiency percentage = (efficiency score / max score) * 100
  • - *
  • Maturity levels are assigned as M1, M2, M3, M4, M5 based on score ceiling
  • + *
  • Category scores = average of constituent KPI maturity values
  • + *
  • Efficiency score = weighted sum of category scores
  • + *
  • Efficiency percentage = (efficiency score / max score) * 100
  • + *
  • Maturity levels are assigned as M1, M2, M3, M4, M5 based on score + * ceiling
  • *
- * @param projectInput the project input data - * @param kpiElementList the retrieved KPI elements response with maturity data - * @return calculated KPI maturity object or {@code null} if calculation not possible + * + * @param projectInput + * the project input data + * @param kpiElementList + * the retrieved KPI elements response with maturity data + * @return calculated KPI maturity object or {@code null} if calculation not + * possible */ private KpiMaturity calculateKpiMaturity(ProjectInputDTO projectInput, List kpiElementList) { if (Boolean.FALSE.equals(kpiMaturityCanBeCalculated(kpiElementList))) { @@ -235,16 +260,20 @@ private KpiMaturity calculateKpiMaturity(ProjectInputDTO projectInput, ListThe efficiency calculation uses a weighted average approach where: + *

+ * The efficiency calculation uses a weighted average approach where: *

    - *
  • Each category has a configured weight (importance factor)
  • - *
  • Efficiency score = Σ(category_weight × category_score)
  • - *
  • Efficiency percentage = (efficiency_score / max_possible_score) × 100
  • + *
  • Each category has a configured weight (importance factor)
  • + *
  • Efficiency score = Σ(category_weight × category_score)
  • + *
  • Efficiency percentage = (efficiency_score / max_possible_score) × + * 100
  • *
* - * @param maturityScoreByCategory map of category names to their calculated maturity scores + * @param maturityScoreByCategory + * map of category names to their calculated maturity scores * @return efficiency score object containing both absolute score and percentage */ private EfficiencyScore calculateEfficiencyScore(Map maturityScoreByCategory) { @@ -267,43 +296,51 @@ private EfficiencyScore calculateEfficiencyScore(Map maturitySco } /** - * Constructs appropriate KPI requests based on project structure and KPI granularity. + * Constructs appropriate KPI requests based on project structure and KPI + * granularity. * - *

This method handles different KPI granularities by creating requests with appropriate: + *

+ * This method handles different KPI granularities by creating requests with + * appropriate: *

    - *
  • Time-based KPIs (MONTH, WEEK, DAY): Use project-level hierarchy with date ranges
  • - *
  • Sprint-based KPIs (SPRINT, ITERATION, PI): Use sprint-level hierarchy with sprint IDs
  • + *
  • Time-based KPIs (MONTH, WEEK, DAY): Use project-level + * hierarchy with date ranges
  • + *
  • Sprint-based KPIs (SPRINT, ITERATION, PI): Use + * sprint-level hierarchy with sprint IDs
  • *
* - *

Request Construction Logic: + *

+ * Request Construction Logic: *

    - *
  • KPIs are grouped by source (Jira, Sonar, Jenkins, etc.)
  • - *
  • Granularity is determined from the first KPI's xAxisLabel in each group
  • - *
  • Request parameters are set based on granularity type
  • + *
  • KPIs are grouped by source (Jira, Sonar, Jenkins, etc.)
  • + *
  • Granularity is determined from the first KPI's xAxisLabel in each + * group
  • + *
  • Request parameters are set based on granularity type
  • *
* - * @param projectInput the project input containing hierarchy and sprint information + * @param projectInput + * the project input containing hierarchy and sprint information * @return list of constructed KPI requests ready for API calls */ private List constructKpiRequests(ProjectInputDTO projectInput) { List kpiRequests = new ArrayList<>(); Map> kpisGroupedBySource = this.kpisEligibleForMaturityCalculation.stream() + .filter(kpiMaster -> kpiMaster.getKanban() == (projectInput.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN)) .collect(Collectors.groupingBy(KpiMaster::getKpiSource)); for (Map.Entry> entry : kpisGroupedBySource.entrySet()) { KpiGranularity kpiGranularity = KpiGranularity.getByKpiXAxisLabel(entry.getValue().get(0).getXAxisLabel()); switch (kpiGranularity) { - case MONTH, WEEK, DAY -> kpiRequests.add(KpiRequest.builder() - .kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList())) - .selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()), - CommonConstant.DATE, List.of("Weeks"))) - .ids(new String[] { String.valueOf( - this.kpiMaturityCalculationConfig.getCalculationConfig().getDataPoints().getCount()) }) - .level(projectInput.hierarchyLevel()).label(projectInput.hierarchyLevelId()).build()); - case SPRINT, ITERATION, NONE, - PI -> - kpiRequests.add(KpiRequest.builder() + case MONTH, WEEK, DAY -> kpiRequests.add(KpiRequest.builder() + .kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList())) + .selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()), + CommonConstant.DATE, List.of(KPI_GRANULARITY_WEEKS))) + .ids(new String[]{String.valueOf( + this.kpiMaturityCalculationConfig.getCalculationConfig().getDataPoints().getCount())}) + .level(projectInput.hierarchyLevel()) + .label(projectInput.hierarchyLevelId()).build()); + case SPRINT, ITERATION, PI -> kpiRequests.add(KpiRequest.builder() .kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList())) .selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT, projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList(), @@ -312,6 +349,27 @@ private List constructKpiRequests(ProjectInputDTO projectInput) { .toArray(String[]::new)) .level(projectInput.sprints().get(0).hierarchyLevel()) .label(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT).build()); + case NONE -> { + if(projectInput.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN) { + kpiRequests.add(KpiRequest.builder() + .kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList())) + .selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()), + CommonConstant.DATE, List.of(KPI_GRANULARITY_WEEKS))) + .ids(new String[]{String.valueOf( + this.kpiMaturityCalculationConfig.getCalculationConfig().getDataPoints().getCount())}) + .level(projectInput.hierarchyLevel()).label(projectInput.hierarchyLevelId()).build()); + } else { + kpiRequests.add(KpiRequest.builder() + .kpiIdList(new ArrayList<>(entry.getValue().stream().map(KpiMaster::getKpiId).toList())) + .selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT, + projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList(), + CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()))) + .ids(projectInput.sprints().stream().map(SprintInputDTO::nodeId).toList() + .toArray(String[]::new)) + .level(projectInput.sprints().get(0).hierarchyLevel()) + .label(CommonConstant.HIERARCHY_LEVEL_ID_SPRINT).build()); + } + } } } return kpiRequests; @@ -320,19 +378,19 @@ private List constructKpiRequests(ProjectInputDTO projectInput) { /** * Loads and filters KPIs that are eligible for maturity calculation. * - *

This method performs a multi-step filtering process: + *

+ * This method performs a multi-step filtering process: *

    - *
  1. Fetch KPIs supporting maturity calculation for SCRUM methodology
  2. - *
  3. Map KPI categories from database configuration
  4. - *
  5. Override categories with values from KpiCategoryMapping if available
  6. - *
  7. Filter KPIs to include only those with configured category weights
  8. + *
  9. Fetch KPIs supporting maturity calculation for SCRUM and KANBAN methodology
  10. + *
  11. Map KPI categories from database configuration
  12. + *
  13. Override categories with values from KpiCategoryMapping if available
  14. + *
  15. Filter KPIs to include only those with configured category weights
  16. *
* * @return filtered list of KPI masters eligible for maturity calculation */ private List loadKpisEligibleForMaturityCalculation() { - List kpiMasterList = this.kpiMasterCustomRepository - .findByDeliveryMethodologyTypeSupportingMaturityCalculation(ProjectDeliveryMethodology.SCRUM).stream() + List kpiMasterList = this.kpiMasterCustomRepository.findKpisSupportingMaturityCalculation().stream() .map(kpiMasterProjection -> { String kpiCategory; if (StringUtils.isEmpty(kpiMasterProjection.getKpiCategory())) { @@ -343,6 +401,7 @@ private List loadKpisEligibleForMaturityCalculation() { return KpiMaster.builder().kpiId(kpiMasterProjection.getKpiId()) .kpiName(kpiMasterProjection.getKpiName()).kpiCategory(kpiCategory.toLowerCase()) .kpiSource(kpiMasterProjection.getKpiSource()) + .kanban(kpiMasterProjection.isKanban()) .xAxisLabel(kpiMasterProjection.getxAxisLabel()).build(); }).toList(); @@ -364,21 +423,26 @@ private List loadKpisEligibleForMaturityCalculation() { } /** - * Determines whether KPI maturity calculation is possible based on available data. + * Determines whether KPI maturity calculation is possible based on available + * data. * - *

Maturity calculation requires at least one KPI element with a numeric - * overall maturity value. This method validates data availability before - * proceeding with expensive calculation operations. + *

+ * Maturity calculation requires at least one KPI element with a numeric overall + * maturity value. This method validates data availability before proceeding + * with expensive calculation operations. * - *

Validation Criteria: + *

+ * Validation Criteria: *

    - *
  • KPI elements list must not be null or empty
  • - *
  • At least one KPI element must have a numeric overallMaturity value
  • - *
  • Numeric validation uses {@link NumberUtils#isNumeric(String)}
  • + *
  • KPI elements list must not be null or empty
  • + *
  • At least one KPI element must have a numeric overallMaturity value
  • + *
  • Numeric validation uses {@link NumberUtils#isNumeric(String)}
  • *
* - * @param kpiElementsResponse the list of KPI elements retrieved from API - * @return {@code true} if maturity calculation can proceed, {@code false} otherwise + * @param kpiElementsResponse + * the list of KPI elements retrieved from API + * @return {@code true} if maturity calculation can proceed, {@code false} + * otherwise */ private static boolean kpiMaturityCanBeCalculated(List kpiElementsResponse) { if (CollectionUtils.isEmpty(kpiElementsResponse)) { diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/strategy/KpiMaturityCalculationJobStrategy.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/strategy/KpiMaturityCalculationJobStrategy.java index c18794836..1d6be6fd0 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/strategy/KpiMaturityCalculationJobStrategy.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/strategy/KpiMaturityCalculationJobStrategy.java @@ -31,7 +31,8 @@ import org.springframework.transaction.PlatformTransactionManager; import com.publicissapient.kpidashboard.common.model.kpimaturity.organization.KpiMaturity; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; import com.publicissapient.kpidashboard.job.config.base.SchedulingConfig; import com.publicissapient.kpidashboard.job.kpimaturitycalculation.config.KpiMaturityCalculationConfig; import com.publicissapient.kpidashboard.job.kpimaturitycalculation.listener.KpiMaturityCalculationJobExecutionListener; @@ -59,7 +60,8 @@ public class KpiMaturityCalculationJobStrategy implements JobStrategy { private final ProjectBatchService projectBatchService; private final KpiMaturityCalculationService kpiMaturityCalculationService; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; @Override public String getJobName() { @@ -71,7 +73,7 @@ public Job getJob() { return new JobBuilder(this.kpiMaturityCalculationConfig.getName(), this.jobRepository) .start(chunkProcessProjects()) .listener(new KpiMaturityCalculationJobExecutionListener(this.projectBatchService, - this.processorExecutionTraceLogServiceImpl)) + this.jobExecutionTraceLogService)) .build(); } @@ -98,7 +100,7 @@ private AsyncItemProcessor asyncProjectProcessor() private AsyncItemWriter asyncItemWriter() { AsyncItemWriter writer = new AsyncItemWriter<>(); - writer.setDelegate(new ProjectItemWriter(this.kpiMaturityCalculationService)); + writer.setDelegate(new ProjectItemWriter(this.kpiMaturityCalculationService, this.processorExecutionTraceLogService)); return writer; } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/writer/ProjectItemWriter.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/writer/ProjectItemWriter.java index 9bd6bfbd0..f7aaea231 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/writer/ProjectItemWriter.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/writer/ProjectItemWriter.java @@ -18,6 +18,8 @@ import java.util.List; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; import org.springframework.lang.NonNull; @@ -32,11 +34,13 @@ @RequiredArgsConstructor public class ProjectItemWriter implements ItemWriter { - private final KpiMaturityCalculationService kpiMaturityCalculationService; + private final KpiMaturityCalculationService kpiMaturityCalculationService; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; - @Override - public void write(@NonNull Chunk chunk) { - log.info("[kpi-maturity-calculation job] Received chunk items for inserting into database with size: {}", chunk.size()); - kpiMaturityCalculationService.saveAll((List) chunk.getItems()); - } + @Override + public void write(@NonNull Chunk chunk) { + log.info("{} Received chunk items for inserting into database with size: {}", + JobConstants.LOG_PREFIX_KPI_MATURITY, chunk.size()); + kpiMaturityCalculationService.saveAll((List) chunk.getItems()); + } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestrator.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestrator.java index f836d9b58..200cdc74a 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestrator.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestrator.java @@ -21,7 +21,6 @@ import java.util.Set; import java.util.stream.Collectors; -import org.apache.commons.collections4.CollectionUtils; import org.bson.types.ObjectId; import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; @@ -29,9 +28,10 @@ import org.springframework.stereotype.Service; import com.publicissapient.kpidashboard.common.constant.ProcessorType; -import com.publicissapient.kpidashboard.common.model.ProcessorExecutionTraceLog; +import com.publicissapient.kpidashboard.common.model.tracelog.JobExecutionTraceLog; import com.publicissapient.kpidashboard.common.model.application.ErrorDetail; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; +import com.publicissapient.kpidashboard.common.constant.ProcessorConstants; import com.publicissapient.kpidashboard.exception.ConcurrentJobExecutionException; import com.publicissapient.kpidashboard.exception.InternalServerErrorException; import com.publicissapient.kpidashboard.exception.JobNotEnabledException; @@ -58,7 +58,7 @@ public class JobOrchestrator { private final AiDataProcessorRepository aiDataProcessorRepository; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; @PostConstruct private void loadAllRegisteredJobs() { @@ -105,22 +105,22 @@ public JobResponseRecord enableJob(String jobName) { public JobExecutionResponseRecord runJob(String jobName) { validateJobCanBeRun(jobName); AiDataProcessor aiDataProcessor = aiDataProcessorRepository.findByProcessorName(jobName); - ProcessorExecutionTraceLog executionTraceLog = this.processorExecutionTraceLogServiceImpl - .createNewProcessorJobExecution(jobName); + JobExecutionTraceLog executionTraceLog = this.jobExecutionTraceLogService + .createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName); try { JobParameters jobParameters = new JobParametersBuilder().addJobParameter("jobName", jobName, String.class) .addJobParameter("executionId", executionTraceLog.getId(), ObjectId.class).toJobParameters(); this.jobLauncher.run(aiDataJobRegistry.getJobStrategy(jobName).getJob(), jobParameters); - return JobExecutionResponseRecord.builder().isRunning(true) - .startedAt(Instant.ofEpochMilli(executionTraceLog.getExecutionStartedAt())).jobName(jobName) + return JobExecutionResponseRecord.builder().isRunning(true) + .startedAt(executionTraceLog.getExecutionStartedAt()).jobName(jobName) .jobId(aiDataProcessor.getId()).executionId(aiDataProcessor.getId()) .executionId(executionTraceLog.getId()).build(); } catch (Exception e) { String errorMessage = String.format("Could not run job '%s' -> '%s", jobName, e.getMessage()); - executionTraceLog.setExecutionEndedAt(Instant.now().toEpochMilli()); + executionTraceLog.setExecutionEndedAt(Instant.now()); executionTraceLog.setExecutionSuccess(false); executionTraceLog.setErrorDetailList(List.of(ErrorDetail.builder().error(errorMessage).build())); - this.processorExecutionTraceLogServiceImpl.saveAiDataProcessorExecutions(executionTraceLog); + this.jobExecutionTraceLogService.updateJobExecution(executionTraceLog); log.error(errorMessage); throw new InternalServerErrorException( String.format("Encountered unexpected error while trying to run job with name '%s'", jobName)); @@ -128,11 +128,8 @@ public JobExecutionResponseRecord runJob(String jobName) { } public boolean jobIsCurrentlyRunning(String jobName) { - List processorExecutionTraceLogs = processorExecutionTraceLogServiceImpl - .findLastExecutionTraceLogsByProcessorName(jobName, 1); - - return CollectionUtils.isNotEmpty(processorExecutionTraceLogs) - && processorExecutionTraceLogs.get(0).isExecutionOngoing(); + return this.jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, + jobName); } private void validateJobCanBeRun(String jobName) { diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/listener/ProductivityCalculationJobExecutionListener.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/listener/ProductivityCalculationJobExecutionListener.java index 8bb6f319b..ff8963cd6 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/listener/ProductivityCalculationJobExecutionListener.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/listener/ProductivityCalculationJobExecutionListener.java @@ -27,9 +27,9 @@ import org.springframework.batch.core.JobParameters; import org.springframework.lang.NonNull; -import com.publicissapient.kpidashboard.common.model.ProcessorExecutionTraceLog; +import com.publicissapient.kpidashboard.common.model.tracelog.JobExecutionTraceLog; import com.publicissapient.kpidashboard.common.model.application.ErrorDetail; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; import com.publicissapient.kpidashboard.job.productivitycalculation.service.ProjectBatchService; import lombok.RequiredArgsConstructor; @@ -40,7 +40,7 @@ public class ProductivityCalculationJobExecutionListener implements JobExecutionListener { private final ProjectBatchService projectBatchService; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; @Override public void afterJob(@NonNull JobExecution jobExecution) { @@ -53,12 +53,12 @@ private void storeJobExecutionStatus(JobExecution jobExecution) { String jobName = jobParameters.getString("jobName"); ObjectId executionId = (ObjectId) Objects.requireNonNull(jobParameters.getParameter("executionId")).getValue(); - Optional processorExecutionTraceLogOptional = this.processorExecutionTraceLogServiceImpl + Optional executionTraceLogOptional = this.jobExecutionTraceLogService .findById(executionId); - if (processorExecutionTraceLogOptional.isPresent()) { - ProcessorExecutionTraceLog executionTraceLog = processorExecutionTraceLogOptional.get(); + if (executionTraceLogOptional.isPresent()) { + JobExecutionTraceLog executionTraceLog = executionTraceLogOptional.get(); executionTraceLog.setExecutionOngoing(false); - executionTraceLog.setExecutionEndedAt(Instant.now().toEpochMilli()); + executionTraceLog.setExecutionEndedAt(Instant.now()); executionTraceLog.setExecutionSuccess(jobExecution.getStatus() == BatchStatus.COMPLETED); executionTraceLog .setErrorDetailList(jobExecution.getAllFailureExceptions().stream().map(failureException -> { @@ -66,7 +66,7 @@ private void storeJobExecutionStatus(JobExecution jobExecution) { errorDetail.setError(failureException.getMessage()); return errorDetail; }).toList()); - this.processorExecutionTraceLogServiceImpl.saveAiDataProcessorExecutions(executionTraceLog); + this.jobExecutionTraceLogService.updateJobExecution(executionTraceLog); } else { log.error("Could not store job execution ending status for job with name {} and execution id {}. Job " + "execution could not be found", jobName, executionId); diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/processor/ProjectItemProcessor.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/processor/ProjectItemProcessor.java index e5d9f1ca4..13083dfd4 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/processor/ProjectItemProcessor.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/processor/ProjectItemProcessor.java @@ -19,6 +19,7 @@ import org.springframework.batch.item.ItemProcessor; import com.publicissapient.kpidashboard.common.model.productivity.calculation.Productivity; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import com.publicissapient.kpidashboard.job.productivitycalculation.service.ProductivityCalculationService; import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; @@ -34,7 +35,7 @@ public class ProjectItemProcessor implements ItemProcessor { public ProjectInputDTO read() { ProjectInputDTO projectInputDTO = projectBatchService.getNextProjectInputData(); - log.info("[productivity-calculation job]Received project input dto {}", projectInputDTO); + log.info("[productivity-calculation job]Received project input dto {}", JobConstants.LOG_PREFIX_PRODUCTIVITY, projectInputDTO); return projectInputDTO; } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProductivityCalculationService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProductivityCalculationService.java index 4a8732c61..55d60c469 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProductivityCalculationService.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProductivityCalculationService.java @@ -40,6 +40,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.math3.util.Precision; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.publicissapient.kpidashboard.client.customapi.KnowHOWClient; import com.publicissapient.kpidashboard.client.customapi.dto.IssueKpiModalValue; @@ -52,6 +53,7 @@ import com.publicissapient.kpidashboard.common.model.productivity.calculation.KPIData; import com.publicissapient.kpidashboard.common.model.productivity.calculation.Productivity; import com.publicissapient.kpidashboard.common.repository.productivity.ProductivityRepository; +import com.publicissapient.kpidashboard.common.shared.enums.ProjectDeliveryMethodology; import com.publicissapient.kpidashboard.job.productivitycalculation.config.ProductivityCalculationConfig; import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; import com.publicissapient.kpidashboard.job.shared.dto.SprintInputDTO; @@ -65,42 +67,54 @@ import lombok.NoArgsConstructor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.transaction.annotation.Transactional; /** - * Service responsible for calculating productivity gains and performance metrics for projects within the KnowHOW platform. + * Service responsible for calculating productivity gains and performance + * metrics for projects within the KnowHOW platform. * - *

Core Business Logic: + *

+ * Core Business Logic: *

    - *
  • Calculates percentage variation for each KPI based on baseline comparison
  • - *
  • Applies configurable weights to different KPIs and categories
  • - *
  • Computes weighted category scores and overall productivity gain
  • - *
  • Handles different KPI granularities (Sprint, Week, Iteration)
  • + *
  • Calculates percentage variation for each KPI based on baseline + * comparison
  • + *
  • Applies configurable weights to different KPIs and categories
  • + *
  • Computes weighted category scores and overall productivity gain
  • + *
  • Handles different KPI granularities (Sprint, Week, Iteration)
  • *
* - *

Calculation Methodology: + *

+ * Calculation Methodology: *

    - *
  1. Baseline Establishment: Uses first non-zero data point as baseline
  2. - *
  3. Trend Analysis: Calculates percentage change from baseline for each subsequent data point
  4. - *
  5. Weight Application: Applies KPI-specific weights (Sprint=2.0, Week=1.0)
  6. - *
  7. Category Aggregation: Averages weighted KPI variations within each category
  8. - *
  9. Overall Score: Weighted sum of category scores using configurable category weights
  10. + *
  11. Baseline Establishment: Uses first non-zero data point + * as baseline
  12. + *
  13. Trend Analysis: Calculates percentage change from + * baseline for each subsequent data point
  14. + *
  15. Weight Application: Applies KPI-specific weights + * (Sprint=2.0, Week=1.0)
  16. + *
  17. Category Aggregation: Averages weighted KPI variations + * within each category
  18. + *
  19. Overall Score: Weighted sum of category scores using + * configurable category weights
  20. *
* - *

Trend Direction Handling: + *

+ * Trend Direction Handling: *

    - *
  • Ascending Trend (Positive): Higher values indicate improvement (e.g., Sprint Velocity)
  • - *
  • Descending Trend (Positive): Lower values indicate improvement (e.g., Defect Density)
  • + *
  • Ascending Trend (Positive): Higher values indicate + * improvement (e.g., Sprint Velocity)
  • + *
  • Descending Trend (Positive): Lower values indicate + * improvement (e.g., Defect Density)
  • *
* - *

Data Processing Flow: + *

+ * Data Processing Flow: *

    - *
  1. Load KPI configurations with weights and trend directions
  2. - *
  3. Construct granularity-specific KPI requests (Sprint/Week/Iteration)
  4. - *
  5. Fetch KPI data from KnowHOW API
  6. - *
  7. Extract and aggregate data points by time periods
  8. - *
  9. Calculate percentage variations and apply weights
  10. - *
  11. Compute category and overall productivity scores
  12. + *
  13. Load KPI configurations with weights and trend directions
  14. + *
  15. Construct granularity-specific KPI requests (Sprint/Week/Iteration)
  16. + *
  17. Fetch KPI data from KnowHOW API
  18. + *
  19. Extract and aggregate data points by time periods
  20. + *
  21. Calculate percentage variations and apply weights
  22. + *
  23. Compute category and overall productivity scores
  24. *
* */ @@ -170,17 +184,21 @@ public void saveAll(List productivityList) { /** * Calculates comprehensive productivity gain assessment for a given project. * - *

This is the main entry point for productivity calculation. The method: + *

+ * This is the main entry point for productivity calculation. The method: *

    - *
  • Validates configuration settings
  • - *
  • Constructs appropriate KPI requests based on project structure
  • - *
  • Fetches KPI data from external sources
  • - *
  • Performs trend analysis and productivity calculations
  • + *
  • Validates configuration settings
  • + *
  • Constructs appropriate KPI requests based on project structure
  • + *
  • Fetches KPI data from external sources
  • + *
  • Performs trend analysis and productivity calculations
  • *
* - * @param projectInputDTO the project data including hierarchy information and sprint details - * @return calculated productivity object with category scores and overall gain metrics, - * or {@code null} if insufficient data is available for calculation + * @param projectInputDTO + * the project data including hierarchy information and sprint + * details + * @return calculated productivity object with category scores and overall gain + * metrics, or {@code null} if insufficient data is available for + * calculation */ public Productivity calculateProductivityGainForProject(ProjectInputDTO projectInputDTO) { if (CollectionUtils @@ -189,6 +207,13 @@ public Productivity calculateProductivityGainForProject(ProjectInputDTO projectI String.join(CommonConstant.COMMA, productivityCalculationJobConfig.getCalculationConfig().getConfigValidationErrors()))); } + if (projectInputDTO.deliveryMethodology() == ProjectDeliveryMethodology.KANBAN) { + log.info( + "Project with node id {} and name {} was skipped from productivity calculation as the delivery methodology Kanban is not supported", + projectInputDTO.nodeId(), projectInputDTO.name()); + // Productivity calculation is not supported for Kanban projects + return null; + } List kpiRequests = constructKpiRequests(projectInputDTO); List kpiElementList = processAllKpiRequests(kpiRequests); @@ -214,24 +239,30 @@ private Map> constructCategoryBasedKPI /** * Performs the core productivity calculation logic for a project. * - *

This method implements the main business logic for: + *

+ * This method implements the main business logic for: *

    - *
  • Validating that productivity calculation is possible
  • - *
  • Computing category-wise productivity gains
  • - *
  • Calculating weighted overall productivity score
  • - *
  • Building the final productivity assessment object
  • + *
  • Validating that productivity calculation is possible
  • + *
  • Computing category-wise productivity gains
  • + *
  • Calculating weighted overall productivity score
  • + *
  • Building the final productivity assessment object
  • *
* - *

Overall Score Calculation: - * The overall productivity gain is computed as a weighted sum of category scores: + *

+ * Overall Score Calculation: The overall productivity gain is + * computed as a weighted sum of category scores: + * *

 	 * Overall = (Speed × SpeedWeight) + (Quality × QualityWeight) +
 	 *           (Productivity × ProductivityWeight) + (Efficiency × EfficiencyWeight)
 	 * 
* - * @param projectInputDTO the project input data - * @param kpisFromAllCategories the retrieved KPI elements from all categories - * @return calculated productivity object or {@code null} if calculation not possible + * @param projectInputDTO + * the project input data + * @param kpisFromAllCategories + * the retrieved KPI elements from all categories + * @return calculated productivity object or {@code null} if calculation not + * possible */ private Productivity calculateProductivityGain(ProjectInputDTO projectInputDTO, List kpisFromAllCategories) { @@ -285,24 +316,32 @@ private Productivity calculateProductivityGain(ProjectInputDTO projectInputDTO, } /** - * Constructs appropriate KPI requests based on project structure and KPI granularity. + * Constructs appropriate KPI requests based on project structure and KPI + * granularity. * - *

This method handles different KPI granularities by creating requests with appropriate parameters: + *

+ * This method handles different KPI granularities by creating requests with + * appropriate parameters: *

    - *
  • WEEK: Project-level requests with date-based filtering
  • - *
  • SPRINT: Sprint-level requests with sprint ID filtering
  • - *
  • ITERATION: Individual sprint requests for detailed analysis
  • + *
  • WEEK: Project-level requests with date-based + * filtering
  • + *
  • SPRINT: Sprint-level requests with sprint ID + * filtering
  • + *
  • ITERATION: Individual sprint requests for detailed + * analysis
  • *
* - *

Request Construction Logic: + *

+ * Request Construction Logic: *

    - *
  1. Group KPIs by their granularity type
  2. - *
  3. Create granularity-specific request parameters
  4. - *
  5. Set appropriate hierarchy levels and filters
  6. - *
  7. Include configured data point counts for historical analysis
  8. + *
  9. Group KPIs by their granularity type
  10. + *
  11. Create granularity-specific request parameters
  12. + *
  13. Set appropriate hierarchy levels and filters
  14. + *
  15. Include configured data point counts for historical analysis
  16. *
* - * @param projectInputDTO the project input containing hierarchy and sprint information + * @param projectInputDTO + * the project input containing hierarchy and sprint information * @return list of constructed KPI requests ready for API calls */ private List constructKpiRequests(ProjectInputDTO projectInputDTO) { @@ -370,29 +409,37 @@ private static List constructKPIDataAndTrendsUsedForProductivityCalcula } /** - * Constructs gain trend calculation data for all KPIs within a specific category. + * Constructs gain trend calculation data for all KPIs within a specific + * category. * - *

This method processes KPI data to calculate productivity variations by: + *

+ * This method processes KPI data to calculate productivity variations by: *

    - *
  • Extracting data points from KPI trend values
  • - *
  • Establishing baseline from first non-zero data point
  • - *
  • Computing percentage variations based on trend direction
  • - *
  • Applying KPI-specific weights to variations
  • + *
  • Extracting data points from KPI trend values
  • + *
  • Establishing baseline from first non-zero data point
  • + *
  • Computing percentage variations based on trend direction
  • + *
  • Applying KPI-specific weights to variations
  • *
* - *

Baseline Selection: - * The baseline is the first data point with a non-zero average value, ensuring - * meaningful percentage calculations. + *

+ * Baseline Selection: The baseline is the first data point + * with a non-zero average value, ensuring meaningful percentage calculations. * - *

Variation Calculation: + *

+ * Variation Calculation: *

    - *
  • Ascending Trend: ((current - baseline) / baseline) × 100
  • - *
  • Descending Trend: ((baseline - current) / baseline) × 100
  • + *
  • Ascending Trend: ((current - baseline) / baseline) × + * 100
  • + *
  • Descending Trend: ((baseline - current) / baseline) × + * 100
  • *
* - * @param kpiIdKpiElementsMap map of KPI IDs to their corresponding elements - * @param projectInputDTO the project input data for context - * @param categoryName the category name for filtering KPIs + * @param kpiIdKpiElementsMap + * map of KPI IDs to their corresponding elements + * @param projectInputDTO + * the project input data for context + * @param categoryName + * the category name for filtering KPIs * @return list of variation calculation data for KPIs in the specified category */ @SuppressWarnings({ "java:S3776", "java:S134" }) @@ -445,22 +492,28 @@ private List constructGainTrendCalculationDataForAl } /** - * Calculates the overall gain for a specific category based on weighted KPI variations. + * Calculates the overall gain for a specific category based on weighted KPI + * variations. * - *

This method computes the category-level productivity gain by: + *

+ * This method computes the category-level productivity gain by: *

    - *
  • Summing all weighted variation products within the category
  • - *
  • Dividing by total weight parts to get weighted average
  • - *
  • Rounding to appropriate precision for consistency
  • + *
  • Summing all weighted variation products within the category
  • + *
  • Dividing by total weight parts to get weighted average
  • + *
  • Rounding to appropriate precision for consistency
  • *
* - *

Formula: + *

+ * Formula: + * *

 	 * Category Gain = Σ(KPI_weighted_variations) / Σ(KPI_weight_parts)
 	 * 
* - * @param kpiVariationCalculationDataListForCategory list of variation calculation data for the category - * @return calculated category gain percentage, or 0.0 if no valid data available + * @param kpiVariationCalculationDataListForCategory + * list of variation calculation data for the category + * @return calculated category gain percentage, or 0.0 if no valid data + * available */ private static double calculateCategorizedGain( List kpiVariationCalculationDataListForCategory) { @@ -482,24 +535,31 @@ private static double calculateCategorizedGain( } /** - * Constructs a map of data points to KPI values for trend analysis and productivity computation. + * Constructs a map of data points to KPI values for trend analysis and + * productivity computation. * - *

This method extracts and organizes KPI values by time periods, handling different - * data structures and granularities: + *

+ * This method extracts and organizes KPI values by time periods, handling + * different data structures and granularities: *

    - *
  • DataCount: Simple time-series data
  • - *
  • DataCountGroup: Filtered and grouped data
  • - *
  • Iteration-based: Sprint-specific calculations
  • + *
  • DataCount: Simple time-series data
  • + *
  • DataCountGroup: Filtered and grouped data
  • + *
  • Iteration-based: Sprint-specific calculations
  • *
* - *

Data Point Organization: - * The returned map uses integer keys representing time periods (0=oldest, n=newest) - * and lists of double values representing KPI measurements for that period. + *

+ * Data Point Organization: The returned map uses integer keys + * representing time periods (0=oldest, n=newest) and lists of double values + * representing KPI measurements for that period. * - * @param kpiConfiguration the KPI configuration with filters and settings - * @param kpiElementsFromProcessorResponse the KPI elements from API response - * @param projectInputDTO the project input data for context - * @return map of data point indices to lists of KPI values, or empty map if no data + * @param kpiConfiguration + * the KPI configuration with filters and settings + * @param kpiElementsFromProcessorResponse + * the KPI elements from API response + * @param projectInputDTO + * the project input data for context + * @return map of data point indices to lists of KPI values, or empty map if no + * data */ @SuppressWarnings({ "java:S3776", "java:S134" }) private static Map> constructKpiValuesByDataPointMap(KPIConfiguration kpiConfiguration, @@ -563,23 +623,30 @@ private static Map> constructKpiValuesByDataPointMap(KPICo return Collections.emptyMap(); } - /** * Populates KPI values for iteration-based KPIs with special calculation logic. * - *

This method handles iteration-based KPIs that require custom data extraction: + *

+ * This method handles iteration-based KPIs that require custom data extraction: *

    - *
  • Wastage KPI (kpi131): Sum of blocked time and wait time
  • - *
  • Work Status KPI (kpi128): Sum of planned delays
  • + *
  • Wastage KPI (kpi131): Sum of blocked time and wait + * time
  • + *
  • Work Status KPI (kpi128): Sum of planned delays
  • *
* - *

The method processes issue-level data to calculate sprint-level aggregates, - * then organizes these values by data point for trend analysis and productivity computation. + *

+ * The method processes issue-level data to calculate sprint-level aggregates, + * then organizes these values by data point for trend analysis and productivity + * computation. * - * @param dataPointAggregatedKpiSumMap the map to populate with data point values - * @param projectInputDTO the project input data with sprint information - * @param kpiData the KPI elements containing issue-level data - * @param kpiId the specific KPI ID for custom calculation logic + * @param dataPointAggregatedKpiSumMap + * the map to populate with data point values + * @param projectInputDTO + * the project input data with sprint information + * @param kpiData + * the KPI elements containing issue-level data + * @param kpiId + * the specific KPI ID for custom calculation logic */ @SuppressWarnings("java:S3776") private static void populateKpiValuesByDataPointMapForIterationBasedKpi( diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProjectBatchService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProjectBatchService.java index 1f95437e3..fa65a8776 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProjectBatchService.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/service/ProjectBatchService.java @@ -36,6 +36,7 @@ import com.publicissapient.kpidashboard.common.repository.application.ProjectBasicConfigRepository; import com.publicissapient.kpidashboard.common.repository.jira.SprintRepositoryCustomImpl; import com.publicissapient.kpidashboard.common.service.HierarchyLevelServiceImpl; +import com.publicissapient.kpidashboard.common.shared.enums.ProjectDeliveryMethodology; import com.publicissapient.kpidashboard.job.productivitycalculation.config.ProductivityCalculationConfig; import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; import com.publicissapient.kpidashboard.job.shared.dto.SprintInputDTO; @@ -169,7 +170,7 @@ private ProjectBatchInputParameters getNextProjectBatchInputParameters() { List sprintDetailsReversed = new ArrayList<>(); - for(int sprintIndex = lastCompletedSprints.size() - 1; sprintIndex > -1; sprintIndex--) { + for (int sprintIndex = lastCompletedSprints.size() - 1; sprintIndex > -1; sprintIndex--) { sprintDetailsReversed.add(lastCompletedSprints.get(sprintIndex)); } @@ -185,21 +186,26 @@ private static List constructProjectInputDTOList(Page> projectObjectIdSprintsMap = projectSprintsDetails.stream() .collect(Collectors.groupingBy(SprintDetails::getBasicProjectConfigId)); - return projectBasicConfigPage.stream() - .filter(projectBasicConfig -> Objects.nonNull(projectBasicConfig.getId()) - && projectObjectIdSprintsMap.containsKey(projectBasicConfig.getId())) - .map(projectBasicConfig -> ProjectInputDTO.builder().name(projectBasicConfig.getProjectName()) - .nodeId(projectBasicConfig.getProjectNodeId()) - .hierarchyLevelId(projectHierarchyLevel.getHierarchyLevelId()) - .hierarchyLevel(projectHierarchyLevel.getLevel()) - .sprints(projectObjectIdSprintsMap.get(projectBasicConfig.getId()).stream() - .map(sprintDetails -> SprintInputDTO.builder() - .hierarchyLevel(sprintHierarchyLevel.getLevel()) - .hierarchyLevelId(sprintHierarchyLevel.getHierarchyLevelId()) - .name(sprintDetails.getSprintName()).nodeId(sprintDetails.getSprintID()) - .build()) - .toList()) - .build()) - .toList(); + return projectBasicConfigPage.stream().filter(projectBasicConfig -> Objects.nonNull(projectBasicConfig.getId())) + .map(projectBasicConfig -> { + ProjectInputDTO.ProjectInputDTOBuilder projectInputDTOBuilder = ProjectInputDTO.builder() + .name(projectBasicConfig.getProjectName()).nodeId(projectBasicConfig.getProjectNodeId()) + .hierarchyLevelId(projectHierarchyLevel.getHierarchyLevelId()) + .hierarchyLevel(projectHierarchyLevel.getLevel()); + if (projectBasicConfig.isKanban()) { + projectInputDTOBuilder.deliveryMethodology(ProjectDeliveryMethodology.KANBAN) + .sprints(List.of()); + } else { + projectInputDTOBuilder.deliveryMethodology(ProjectDeliveryMethodology.SCRUM) + .sprints(projectObjectIdSprintsMap.get(projectBasicConfig.getId()).stream() + .map(sprintDetails -> SprintInputDTO.builder() + .hierarchyLevel(sprintHierarchyLevel.getLevel()) + .hierarchyLevelId(sprintHierarchyLevel.getHierarchyLevelId()) + .name(sprintDetails.getSprintName()).nodeId(sprintDetails.getSprintID()) + .build()) + .toList()); + } + return projectInputDTOBuilder.build(); + }).toList(); } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/strategy/ProductivityCalculationJobStrategy.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/strategy/ProductivityCalculationJobStrategy.java index 42c6782a7..d89dacce5 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/strategy/ProductivityCalculationJobStrategy.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/strategy/ProductivityCalculationJobStrategy.java @@ -31,7 +31,8 @@ import org.springframework.transaction.PlatformTransactionManager; import com.publicissapient.kpidashboard.common.model.productivity.calculation.Productivity; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; import com.publicissapient.kpidashboard.job.config.base.SchedulingConfig; import com.publicissapient.kpidashboard.job.productivitycalculation.config.ProductivityCalculationConfig; import com.publicissapient.kpidashboard.job.productivitycalculation.listener.ProductivityCalculationJobExecutionListener; @@ -58,7 +59,8 @@ public class ProductivityCalculationJobStrategy implements JobStrategy { private final ProjectBatchService projectBatchService; private final ProductivityCalculationService productivityCalculationService; - private final ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private final JobExecutionTraceLogService jobExecutionTraceLogService; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; @Override public String getJobName() { @@ -74,7 +76,7 @@ public Optional getSchedulingConfig() { public Job getJob() { return new JobBuilder(productivityCalculationJobConfig.getName(), jobRepository).start(chunkProcessProjects()) .listener(new ProductivityCalculationJobExecutionListener(this.projectBatchService, - this.processorExecutionTraceLogServiceImpl)) + this.jobExecutionTraceLogService)) .build(); } @@ -95,7 +97,7 @@ private AsyncItemProcessor asyncProjectProcessor( private AsyncItemWriter asyncItemWriter() { AsyncItemWriter writer = new AsyncItemWriter<>(); - writer.setDelegate(new ProjectItemWriter(this.productivityCalculationService)); + writer.setDelegate(new ProjectItemWriter(this.productivityCalculationService, this.processorExecutionTraceLogService)); return writer; } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/writer/ProjectItemWriter.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/writer/ProjectItemWriter.java index a56da4d92..8fb34fe04 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/writer/ProjectItemWriter.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/productivitycalculation/writer/ProjectItemWriter.java @@ -18,6 +18,8 @@ import java.util.List; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; import org.springframework.lang.NonNull; @@ -33,10 +35,12 @@ public class ProjectItemWriter implements ItemWriter { private final ProductivityCalculationService productivityCalculationService; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; @Override public void write(@NonNull Chunk chunk) { - log.info("[productivity-calculation job] Received chunk items for inserting into database with size: {}", chunk.size()); - productivityCalculationService.saveAll((List) chunk.getItems()); + log.info("{} Received chunk items for inserting into database with size: {}", + JobConstants.LOG_PREFIX_PRODUCTIVITY, chunk.size()); + productivityCalculationService.saveAll((List) chunk.getItems()); } } diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/CalculationConfig.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/CalculationConfig.java new file mode 100644 index 000000000..d0d557d4e --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/CalculationConfig.java @@ -0,0 +1,57 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.config; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.apache.commons.collections4.CollectionUtils; + +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Persona; +import com.publicissapient.kpidashboard.job.config.validator.ConfigValidator; + +import lombok.Data; + +/** + * Configuration class for recommendation calculation job. + */ +@Data +public class CalculationConfig implements ConfigValidator { + + private Set configValidationErrors = new HashSet<>(); + + private Persona enabledPersona; + private List kpiList; + + @Override + public void validateConfiguration() { + if (enabledPersona == null) { + configValidationErrors.add("No enabled persona configured for recommendation calculation"); + } + if (CollectionUtils.isEmpty(kpiList)) { + configValidationErrors.add("No KPI list configured for recommendation calculation"); + } + } + + @Override + public Set getConfigValidationErrors() { + return Collections.unmodifiableSet(configValidationErrors); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/RecommendationCalculationConfig.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/RecommendationCalculationConfig.java new file mode 100644 index 000000000..5ff694581 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/RecommendationCalculationConfig.java @@ -0,0 +1,108 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.config; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import com.knowhow.retro.aigatewayclient.client.config.AiGatewayConfig; +import com.knowhow.retro.aigatewayclient.m2mauth.config.M2MAuthConfig; +import com.publicissapient.kpidashboard.job.config.base.BatchConfig; +import com.publicissapient.kpidashboard.job.config.base.SchedulingConfig; +import com.publicissapient.kpidashboard.job.config.validator.ConfigValidator; + +import jakarta.annotation.PostConstruct; +import lombok.Data; + +/** + * Main configuration class for recommendation calculation job. + */ +@Data +@Component +@ConfigurationProperties(prefix = "jobs.recommendation-calculation") +public class RecommendationCalculationConfig implements ConfigValidator { + + private final M2MAuthConfig m2MAuthConfig; + private final AiGatewayConfig aiGatewayConfig; + private String name; + private BatchConfig batching; + private SchedulingConfig scheduling; + private CalculationConfig calculationConfig; + private Set configValidationErrors = new HashSet<>(); + + @Autowired + public RecommendationCalculationConfig(M2MAuthConfig m2MAuthConfig, AiGatewayConfig aiGatewayConfig) { + this.m2MAuthConfig = m2MAuthConfig; + this.aiGatewayConfig = aiGatewayConfig; + } + + @Override + public void validateConfiguration() { + if (StringUtils.isEmpty(this.name)) { + configValidationErrors.add("The job 'name' parameter is required"); + } + + // Validate M2M Auth configuration + if (m2MAuthConfig == null) { + configValidationErrors.add("M2M authentication configuration is required for AI Gateway access"); + } else { + if (StringUtils.isEmpty(m2MAuthConfig.getIssuerServiceId())) { + configValidationErrors.add("M2M auth 'issuerServiceId' is required"); + } + if (StringUtils.isEmpty(m2MAuthConfig.getSecret())) { + configValidationErrors.add("M2M auth 'secret' is required"); + } + } + + // Validate AI Gateway configuration + if (aiGatewayConfig == null) { + configValidationErrors.add("AI Gateway configuration is required for recommendation calculation"); + } else { + if (StringUtils.isEmpty(aiGatewayConfig.getBaseUrl())) { + configValidationErrors.add("AI Gateway 'baseUrl' is required"); + } + if (StringUtils.isEmpty(aiGatewayConfig.getAudience())) { + configValidationErrors.add("AI Gateway 'audience' is required"); + } + } + } + + @Override + public Set getConfigValidationErrors() { + return Collections.unmodifiableSet(this.configValidationErrors); + } + + @PostConstruct + private void retrieveJobConfigValidationErrors() { + this.validateConfiguration(); + + this.calculationConfig.validateConfiguration(); + this.batching.validateConfiguration(); + this.scheduling.validateConfiguration(); + + this.configValidationErrors.addAll(this.calculationConfig.getConfigValidationErrors()); + this.configValidationErrors.addAll(this.batching.getConfigValidationErrors()); + this.configValidationErrors.addAll(this.scheduling.getConfigValidationErrors()); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/listener/RecommendationCalculationJobExecutionListener.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/listener/RecommendationCalculationJobExecutionListener.java new file mode 100644 index 000000000..36e12e734 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/listener/RecommendationCalculationJobExecutionListener.java @@ -0,0 +1,109 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.listener; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +import com.knowhow.retro.aigatewayclient.client.response.aiproviders.AiProvidersResponseDTO; +import org.bson.types.ObjectId; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.JobParameters; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; + +import com.knowhow.retro.aigatewayclient.client.AiGatewayClient; +import com.knowhow.retro.aigatewayclient.exception.AiGatewayInitializationException; +import com.publicissapient.kpidashboard.common.model.application.ErrorDetail; +import com.publicissapient.kpidashboard.common.model.tracelog.JobExecutionTraceLog; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationProjectBatchService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Job execution listener for recommendation calculation job. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class RecommendationCalculationJobExecutionListener implements JobExecutionListener { + + private final RecommendationProjectBatchService projectBatchService; + private final JobExecutionTraceLogService jobExecutionTraceLogService; + private final AiGatewayClient aiGatewayClient; + + /** + * Validates AI Gateway configuration before job execution starts. + * + * @param jobExecution + * the job execution context + * @throws AiGatewayInitializationException + * if AI Gateway configuration is invalid + */ + @Override + public void beforeJob(@NonNull JobExecution jobExecution) { + log.info("{} Validating AI Gateway configuration before job execution", JobConstants.LOG_PREFIX_RECOMMENDATION); + + // Validate AI Gateway configuration using the client's built-in validator + AiProvidersResponseDTO aiProviders = aiGatewayClient.getProviders(); + + log.info("{} AI Gateway configuration validated successfully. Available providers: {}", + JobConstants.LOG_PREFIX_RECOMMENDATION, aiProviders); + } + + @Override + public void afterJob(@NonNull JobExecution jobExecution) { + log.info("{} Job completed with status: {}", JobConstants.LOG_PREFIX_RECOMMENDATION, + jobExecution.getStatus()); + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + storeJobExecutionStatus(jobExecution); + } + + private void storeJobExecutionStatus(JobExecution jobExecution) { + JobParameters jobParameters = jobExecution.getJobParameters(); + String jobName = jobParameters.getString("jobName"); + ObjectId executionId = (ObjectId) Objects.requireNonNull(jobParameters.getParameter("executionId")).getValue(); + + Optional executionTraceLogOptional = this.jobExecutionTraceLogService + .findById(executionId); + if (executionTraceLogOptional.isPresent()) { + JobExecutionTraceLog executionTraceLog = executionTraceLogOptional.get(); + executionTraceLog.setExecutionOngoing(false); + executionTraceLog.setExecutionEndedAt(Instant.now()); + executionTraceLog.setExecutionSuccess(jobExecution.getStatus() == BatchStatus.COMPLETED); + executionTraceLog + .setErrorDetailList(jobExecution.getAllFailureExceptions().stream().map(failureException -> { + ErrorDetail errorDetail = new ErrorDetail(); + errorDetail.setError(failureException.getMessage()); + return errorDetail; + }).toList()); + this.jobExecutionTraceLogService.updateJobExecution(executionTraceLog); + } else { + log.error( + "{} Could not store job execution ending status for job with name {} and execution id {}. Job " + + "execution could not be found", + JobConstants.LOG_PREFIX_RECOMMENDATION, jobName, executionId); + } + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParser.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParser.java new file mode 100644 index 000000000..f8b3c85d6 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParser.java @@ -0,0 +1,225 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.parser; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.knowhow.retro.aigatewayclient.client.response.chat.ChatGenerationResponseDTO; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.ActionPlan; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Recommendation; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Severity; +import com.publicissapient.kpidashboard.job.constant.JobConstants; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Parser for batch processor AI Gateway responses. Converts AI-generated JSON + * responses into structured Recommendation objects. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class BatchRecommendationResponseParser { + + public static final String TITLE = "title"; + public static final String DESCRIPTION = "description"; + public static final String RECOMMENDATIONS = "recommendations"; + public static final String SEVERITY = "severity"; + public static final String ACTION_PLANS = "actionPlans"; + public static final String TIME_TO_VALUE = "timeToValue"; + private static final String MARKDOWN_CODE_FENCE = "```"; + private static final char JSON_START_CHAR = '{'; + private static final String EMPTY_JSON_OBJECT = "{}"; + private final ObjectMapper objectMapper; + + /** + * Parses AI response into a Recommendation object. Validates response content + * and structure. + * + * @param response + * ChatGenerationResponseDTO from AI Gateway + * @return Optional containing parsed Recommendation, or empty if parsing fails + * @throws IllegalArgumentException + * if response is null + */ + public Optional parseRecommendation(ChatGenerationResponseDTO response) { + if (response == null) { + throw new IllegalArgumentException("AI Gateway response cannot be null"); + } + + // Validate response content is not null or empty + String aiResponse = response.content(); + if (aiResponse == null || aiResponse.trim().isEmpty()) { + log.error("{} AI Gateway returned null or empty response content", + JobConstants.LOG_PREFIX_RECOMMENDATION); + return Optional.empty(); + } + + return parseRecommendationContent(aiResponse); + } + + /** + * Parses AI response content into a Recommendation object. + * + * @param aiResponse + * JSON string from AI Gateway + * @return Optional containing parsed Recommendation, or empty if parsing fails + */ + private Optional parseRecommendationContent(String aiResponse) { + if (StringUtils.isBlank(aiResponse)) { + log.error("{} AI response is empty, cannot parse recommendation", + JobConstants.LOG_PREFIX_RECOMMENDATION); + return Optional.empty(); + } + + try { + String jsonContent = extractJsonContent(aiResponse); + + if (StringUtils.isBlank(jsonContent) || EMPTY_JSON_OBJECT.equals(jsonContent)) { + log.error("{} Extracted JSON content is empty or invalid from AI response", + JobConstants.LOG_PREFIX_RECOMMENDATION); + return Optional.empty(); + } + JsonNode rootNode = objectMapper.readTree(jsonContent); + + // Check for direct recommendation object with required non-empty fields + if (hasValidTextField(rootNode, TITLE) && hasValidTextField(rootNode, DESCRIPTION)) { + return Optional.of(parseRecommendationNode(rootNode)); + } + + // Check for recommendations array + return Optional.ofNullable(rootNode.get(RECOMMENDATIONS)).filter(JsonNode::isArray) + .filter(node -> !node.isEmpty()).map(node -> parseRecommendationNode(node.get(0))); + + } catch (Exception e) { + String preview = StringUtils.abbreviate(aiResponse, 100); + log.error("{} Error parsing AI response JSON: {} - Response preview: {}", + JobConstants.LOG_PREFIX_RECOMMENDATION, e.getMessage(), preview, e); + return Optional.empty(); + } + } + + /** + * Extracts JSON content from AI response by removing markdown code blocks. + * Handles responses wrapped in ```json``` markdown blocks. + * + * @param aiResponse + * the raw AI response string + * @return extracted JSON content, or empty JSON object if extraction fails + */ + private String extractJsonContent(String aiResponse) { + String content = StringUtils.defaultIfBlank(aiResponse, EMPTY_JSON_OBJECT).trim(); + + // Remove markdown code blocks if present + if (content.startsWith(MARKDOWN_CODE_FENCE)) { + content = StringUtils.substringBetween(content, "\n", MARKDOWN_CODE_FENCE); + if (content == null) { + return EMPTY_JSON_OBJECT; + } + } + + // Find and extract JSON object starting from first { + int jsonStart = content.indexOf(JSON_START_CHAR); + return jsonStart >= 0 ? content.substring(jsonStart) : content; + } + + /** + * Parses a JSON node into a Recommendation object. Extracts all fields directly + * from AI response. + * + * @param node + * the JSON node containing recommendation data + * @return parsed Recommendation object with values exactly as provided by AI + */ + private Recommendation parseRecommendationNode(JsonNode node) { + // Parse severity directly from AI response + Severity severity = Optional.ofNullable(getTextValue(node, SEVERITY)).map(String::toUpperCase) + .flatMap(this::parseSeverity).orElse(null); + + // Parse action plans + List actionPlans = Optional.ofNullable(node.get(ACTION_PLANS)).filter(JsonNode::isArray) + .map(this::parseActionPlans).orElse(null); + + // Build recommendation using builder + return Recommendation.builder().title(getTextValue(node, TITLE)).description(getTextValue(node, DESCRIPTION)) + .severity(severity).timeToValue(getTextValue(node, TIME_TO_VALUE)).actionPlans(actionPlans).build(); + } + + /** + * Safely parses severity enum value. + */ + private Optional parseSeverity(String severityStr) { + try { + return Optional.of(Severity.valueOf(severityStr)); + } catch (IllegalArgumentException e) { + log.warn("{} Invalid severity value from AI response: {}. Saving as null.", + JobConstants.LOG_PREFIX_RECOMMENDATION, severityStr); + return Optional.empty(); + } + } + + /** + * Parses action plans from JSON array node. + */ + private List parseActionPlans(JsonNode actionPlansNode) { + List actionPlans = new ArrayList<>(); + actionPlansNode.forEach(actionNode -> { + ActionPlan action = ActionPlan.builder().title(getTextValue(actionNode, TITLE)) + .description(getTextValue(actionNode, DESCRIPTION)).build(); + actionPlans.add(action); + }); + return actionPlans; + } + + /** + * Checks if JSON node has a valid non-empty text field. + * + * @param node + * the JSON node to check + * @param fieldName + * the field name to check + * @return true if field exists and has non-blank text + */ + private boolean hasValidTextField(JsonNode node, String fieldName) { + return Optional.ofNullable(node.get(fieldName)).map(JsonNode::asText).filter(StringUtils::isNotBlank) + .isPresent(); + } + + /** + * Safely extracts text value from JSON node. Returns null if field doesn't + * exist or is null. + * + * @param node + * the JSON node to extract from + * @param fieldName + * the field name to extract + * @return extracted text value, or null if not present + */ + private String getTextValue(JsonNode node, String fieldName) { + return Optional.ofNullable(node.get(fieldName)).map(JsonNode::asText).filter(StringUtils::isNotBlank) + .orElse(null); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/processor/ProjectItemProcessor.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/processor/ProjectItemProcessor.java new file mode 100644 index 000000000..b9b4fd385 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/processor/ProjectItemProcessor.java @@ -0,0 +1,81 @@ +/* + * Copyright 2024 Sapient Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the + * License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.processor; + +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.batch.item.ItemProcessor; + +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationCalculationService; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +import jakarta.annotation.Nonnull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Spring Batch ItemProcessor for processing project recommendations. + */ +@Slf4j +@RequiredArgsConstructor +public class ProjectItemProcessor implements ItemProcessor { + + private final RecommendationCalculationService recommendationCalculationService; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; + + /** + * Processes a single project to generate AI recommendations. Handles errors + * gracefully by logging and saving failure trace. + * + * @param projectInputDTO + * the project input data (must not be null) + * @return RecommendationsActionPlan if successful, null if processing fails + * @throws Exception + * if fatal error occurs (Spring Batch will handle retry/skip logic) + */ + @Override + public RecommendationsActionPlan process(@Nonnull ProjectInputDTO projectInputDTO) throws Exception { + try { + log.debug("{} Starting recommendation calculation for project with nodeId: {}", + JobConstants.LOG_PREFIX_RECOMMENDATION, projectInputDTO.nodeId()); + + RecommendationsActionPlan recommendation = recommendationCalculationService + .calculateRecommendationsForProject(projectInputDTO); + + log.debug("{} Generated recommendation plan for project: {} with persona: {}", + JobConstants.LOG_PREFIX_RECOMMENDATION, projectInputDTO.name(), + recommendation.getMetadata().getPersona()); + return recommendation; + } catch (Exception e) { + log.error("{} Failed to process project: {} (nodeId: {})", + JobConstants.LOG_PREFIX_RECOMMENDATION, projectInputDTO.name(), + projectInputDTO.nodeId(), e); + + // Save detailed failure trace log with more context + String errorMessage = String.format("Processing failed for project %s: %s - %s. Root cause: %s", + projectInputDTO.name(), e.getClass().getSimpleName(), e.getMessage(), + ExceptionUtils.getRootCauseMessage(e)); + processorExecutionTraceLogService.upsertTraceLog(JobConstants.JOB_RECOMMENDATION_CALCULATION, + projectInputDTO.nodeId(), false, errorMessage); + + // Return null to skip this projectInputDTO + return null; + } + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/reader/ProjectItemReader.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/reader/ProjectItemReader.java new file mode 100644 index 000000000..79475cf5c --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/reader/ProjectItemReader.java @@ -0,0 +1,47 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.reader; + +import org.springframework.batch.item.ItemReader; + +import com.publicissapient.kpidashboard.job.constant.JobConstants; +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationProjectBatchService; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Spring Batch ItemReader for reading project input data. + */ +@Slf4j +@RequiredArgsConstructor +public class ProjectItemReader implements ItemReader { + + private final RecommendationProjectBatchService projectBatchService; + + @Override + public ProjectInputDTO read() { + ProjectInputDTO projectInputDTO = projectBatchService.getNextProjectInputData(); + + log.info("{} Received project input dto {}", JobConstants.LOG_PREFIX_RECOMMENDATION, + projectInputDTO); + + return projectInputDTO; + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/KpiDataExtractionService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/KpiDataExtractionService.java new file mode 100644 index 000000000..71dc9d7c7 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/KpiDataExtractionService.java @@ -0,0 +1,180 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.stereotype.Service; + +import com.publicissapient.kpidashboard.client.customapi.KnowHOWClient; +import com.publicissapient.kpidashboard.client.customapi.dto.KpiElement; +import com.publicissapient.kpidashboard.client.customapi.dto.KpiRequest; +import com.publicissapient.kpidashboard.common.constant.CommonConstant; +import com.publicissapient.kpidashboard.common.model.application.DataCount; +import com.publicissapient.kpidashboard.common.model.application.DataCountGroup; +import com.publicissapient.kpidashboard.common.model.application.KpiDataPrompt; +import com.publicissapient.kpidashboard.job.constant.JobConstants; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service responsible for extracting and transforming KPI data from KnowHOW + * API. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KpiDataExtractionService { + + private static final List FILTER_LIST = Arrays.asList("Final Scope (Story Points)", "Average Coverage", + "Story Points", "Overall"); + private final KnowHOWClient knowHOWClient; + private final RecommendationCalculationConfig recommendationCalculationConfig; + + /** + * Fetches and extracts KPI data for the given project. + * + * @param projectInput + * the project input containing hierarchy information + * @return map of KPI name to formatted KPI data prompts + */ + public Map fetchKpiDataForProject(ProjectInputDTO projectInput) { + try { + log.debug("{} Fetching KPI data for project: {}", JobConstants.LOG_PREFIX_RECOMMENDATION, + projectInput.nodeId()); + + // Construct KPI requests + List kpiRequests = constructKpiRequests(projectInput); + + // Fetch from KnowHOW API + List kpiElements = knowHOWClient.getKpiIntegrationValues(kpiRequests); + + // Validate KPI elements were received + if (CollectionUtils.isEmpty(kpiElements)) { + log.error( + "{} No KPI elements received from KnowHOW API for project: {}. Failing recommendation calculation.", + JobConstants.LOG_PREFIX_RECOMMENDATION, projectInput.nodeId()); + throw new IllegalStateException( + "No KPI data received from KnowHOW API for project: " + projectInput.nodeId()); + } + + // Extract and format KPI data + Map kpiData = extractKpiData(kpiElements); + + // Validate that extracted KPI data has meaningful content + boolean hasData = kpiData.values().stream() + .anyMatch(value -> value instanceof List && !((List) value).isEmpty()); + + if (!hasData) { + log.error( + "{} KPI data extraction resulted in empty values for all KPIs for project: {}. Failing recommendation calculation.", + JobConstants.LOG_PREFIX_RECOMMENDATION, projectInput.nodeId()); + throw new IllegalStateException( + "No meaningful KPI data available for project: " + projectInput.nodeId()); + } + + log.debug("{} Successfully fetched {} KPIs for project: {}", JobConstants.LOG_PREFIX_RECOMMENDATION, + kpiData.size(), projectInput.nodeId()); + return kpiData; + + } catch (Exception e) { + log.error("{} Error fetching KPI data for project {}: {}", JobConstants.LOG_PREFIX_RECOMMENDATION, + projectInput.nodeId(), e.getMessage(), e); + throw e; + } + } + + /** + * Constructs KPI requests for the given project. + * + * @param projectInput + * the project input containing hierarchy information + * @return list of KPI requests ready for API calls + */ + private List constructKpiRequests(ProjectInputDTO projectInput) { + KpiRequest kpiRequest = KpiRequest.builder() + .kpiIdList(recommendationCalculationConfig.getCalculationConfig().getKpiList()) + .selectedMap(Map.of(CommonConstant.HIERARCHY_LEVEL_ID_PROJECT, List.of(projectInput.nodeId()))) + .ids(new String[] { projectInput.nodeId() }).level(projectInput.hierarchyLevel()) + .label(projectInput.hierarchyLevelId()).build(); + + return List.of(kpiRequest); + } + + /** + * Extracts and formats KPI data from KPI elements. + * + * @param kpiElements + * the list of KPI elements from KnowHOW API + * @return map where key is KPI name and value is list of formatted data prompts + */ + @SuppressWarnings("unchecked") + private Map extractKpiData(List kpiElements) { + Map kpiDataMap = new HashMap<>(); + + kpiElements.forEach(kpiElement -> { + List kpiDataPromptList = new ArrayList<>(); + Object trendValueObj = kpiElement.getTrendValueList(); + + // Handle both List and non-List types + if (trendValueObj instanceof List trendValueList && CollectionUtils.isNotEmpty(trendValueList)) { + DataCount dataCount = trendValueList.get(0) instanceof DataCountGroup + ? ((List) trendValueList).stream().filter(this::matchesFilterCriteria) + .map(DataCountGroup::getValue).flatMap(List::stream).findFirst().orElse(null) + : ((List) trendValueList).get(0); + + if (dataCount != null && dataCount.getValue() instanceof List) { + ((List) dataCount.getValue()).forEach(dataCountItem -> { + KpiDataPrompt kpiDataPrompt = new KpiDataPrompt(); + kpiDataPrompt.setData(dataCountItem.getData()); + kpiDataPrompt.setSProjectName(dataCountItem.getSProjectName()); + kpiDataPrompt.setSSprintName(dataCountItem.getsSprintName()); + kpiDataPrompt.setDate(dataCountItem.getDate()); + kpiDataPromptList.add(kpiDataPrompt.toString()); + }); + } + } else if (trendValueObj != null) { + log.debug("{} Skipping non-list trendValueList for KPI {}: {} (type: {})", + JobConstants.LOG_PREFIX_RECOMMENDATION, kpiElement.getKpiId(), + kpiElement.getKpiName(), trendValueObj.getClass().getSimpleName()); + } + kpiDataMap.put(kpiElement.getKpiName(), kpiDataPromptList); + }); + + return kpiDataMap; + } /** + * Checks if DataCountGroup matches filter criteria. Matches if either the main + * filter is in FILTER_LIST, or both filter1 and filter2 are in FILTER_LIST. + * + * @param trend + * the DataCountGroup to check + * @return true if trend matches filter criteria + */ + private boolean matchesFilterCriteria(DataCountGroup trend) { + return FILTER_LIST.contains(trend.getFilter()) + || (FILTER_LIST.contains(trend.getFilter1()) && FILTER_LIST.contains(trend.getFilter2())); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationCalculationService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationCalculationService.java new file mode 100644 index 000000000..a4fdf0f82 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationCalculationService.java @@ -0,0 +1,163 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.service; + +import java.time.Instant; +import java.util.Map; + +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; + +import com.knowhow.retro.aigatewayclient.client.AiGatewayClient; +import com.knowhow.retro.aigatewayclient.client.request.chat.ChatGenerationRequest; +import com.knowhow.retro.aigatewayclient.client.response.chat.ChatGenerationResponseDTO; +import com.publicissapient.kpidashboard.common.constant.CommonConstant; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Persona; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Recommendation; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationLevel; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationMetadata; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.service.recommendation.PromptService; +import com.publicissapient.kpidashboard.config.mongo.TTLIndexConfigProperties; +import com.publicissapient.kpidashboard.job.constant.JobConstants; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.parser.BatchRecommendationResponseParser; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service responsible for orchestrating AI-based recommendation generation. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RecommendationCalculationService { + public static final String RECOMMENDATION_CALCULATION = "recommendation-calculation"; + private final AiGatewayClient aiGatewayClient; + private final KpiDataExtractionService kpiDataExtractionService; + private final PromptService promptService; + private final BatchRecommendationResponseParser recommendationResponseParser; + private final RecommendationCalculationConfig recommendationCalculationConfig; + private final TTLIndexConfigProperties ttlIndexConfigProperties; + + /** + * Calculates AI-generated recommendations for a given project. Orchestrates KPI + * data extraction, prompt building, AI generation, and validation. + * + * @param projectInput + * the project input containing hierarchy and sprint information + * (must not be null) + * @return recommendation action plan with validated AI recommendations + * @throws IllegalStateException + * if AI response parsing or validation fails or if configuration is + * invalid + */ + public RecommendationsActionPlan calculateRecommendationsForProject(@NonNull ProjectInputDTO projectInput) { + if (CollectionUtils.isNotEmpty(recommendationCalculationConfig.getConfigValidationErrors())) { + throw new IllegalStateException(String.format("The following config validation errors occurred: %s", + String.join(CommonConstant.COMMA, recommendationCalculationConfig.getConfigValidationErrors()))); + } + + Persona persona = recommendationCalculationConfig.getCalculationConfig().getEnabledPersona(); + + log.info("{} Calculating recommendations for project: {} ({}) - Persona: {}", + JobConstants.LOG_PREFIX_RECOMMENDATION, projectInput.name(), projectInput.nodeId(), + persona.getDisplayName()); + + // Delegate KPI data extraction to specialized service + Map kpiData = kpiDataExtractionService.fetchKpiDataForProject(projectInput); + + // Build prompt using PromptService with actual KPI data + String prompt = promptService.getKpiRecommendationPrompt(kpiData, persona); + + // Validate prompt was generated successfully + if (prompt == null || prompt.trim().isEmpty()) { + throw new IllegalStateException("Failed to generate valid prompt for project: " + projectInput.nodeId()); + } + + ChatGenerationRequest request = ChatGenerationRequest.builder().prompt(prompt).build(); + + ChatGenerationResponseDTO response = aiGatewayClient.generate(request); + + // Validate AI Gateway returned a response + if (response == null) { + throw new IllegalStateException("AI Gateway returned null response for project: " + projectInput.nodeId()); + } + + return buildRecommendationsActionPlan(projectInput, persona, response); + } + + /** + * Builds recommendation action plan from AI response and project metadata. + * Parses AI response, validates using RecommendationValidator, and constructs + * complete plan. + * + * @param projectInput + * the project input data + * @param persona + * the persona used for recommendations + * @param response + * the AI response DTO + * @return complete recommendation action plan with metadata + * @throws IllegalStateException + * if parsing or validation fails + */ + private RecommendationsActionPlan buildRecommendationsActionPlan(ProjectInputDTO projectInput, Persona persona, + ChatGenerationResponseDTO response) { + + Instant now = Instant.now(); + + // Parse and validate AI response + Recommendation recommendation = recommendationResponseParser.parseRecommendation(response) + .orElseThrow(() -> new IllegalStateException( + "Failed to parse AI recommendation for project: " + projectInput.nodeId())); // Build metadata + RecommendationMetadata metadata = RecommendationMetadata.builder() + .requestedKpis(recommendationCalculationConfig.getCalculationConfig().getKpiList()).persona(persona) + .build(); + + // Build plan using builder + return RecommendationsActionPlan.builder().basicProjectConfigId(projectInput.basicProjectConfigId()) + .projectName(projectInput.name()).persona(persona).level(RecommendationLevel.PROJECT_LEVEL) + .createdAt(now).expiresOn(now.plusSeconds(getTtlExpirationSeconds())).recommendations(recommendation) + .metadata(metadata).build(); + } + + /** + * Calculates TTL expiration duration in seconds. Reads from + * mongo.ttl-index.configs.recommendation-calculation configuration. + * + * @return TTL expiration time in seconds + * @throws IllegalStateException + * if TTL configuration not found + */ + private long getTtlExpirationSeconds() { + TTLIndexConfigProperties.TTLIndexConfig ttlConfig = ttlIndexConfigProperties.getConfigs() + .get(RECOMMENDATION_CALCULATION); + + if (ttlConfig == null) { + log.error("{} TTL configuration 'recommendation-calculation' not found in mongo.ttl-index.configs", + JobConstants.LOG_PREFIX_RECOMMENDATION); + throw new IllegalStateException("TTL configuration for recommendation-calculation is not configured"); + } + + return ttlConfig.getTimeUnit().toSeconds(ttlConfig.getExpiration()); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationProjectBatchService.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationProjectBatchService.java new file mode 100644 index 000000000..b0627dbea --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationProjectBatchService.java @@ -0,0 +1,159 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.service; + +import java.util.Collections; +import java.util.List; + +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import com.publicissapient.kpidashboard.common.model.application.HierarchyLevel; +import com.publicissapient.kpidashboard.common.model.application.ProjectBasicConfig; +import com.publicissapient.kpidashboard.common.repository.application.ProjectBasicConfigRepository; +import com.publicissapient.kpidashboard.common.service.HierarchyLevelServiceImpl; +import com.publicissapient.kpidashboard.job.constant.JobConstants; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +import jakarta.annotation.PostConstruct; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service for batching projects during recommendation calculation. + */ +@Slf4j +@Component +@JobScope +@RequiredArgsConstructor +public class RecommendationProjectBatchService { + + private final RecommendationCalculationConfig recommendationCalculationConfig; + private final ProjectBasicConfigRepository projectBasicConfigRepository; + private final HierarchyLevelServiceImpl hierarchyLevelServiceImpl; + + private ProjectBatchProcessingParameters processingParameters; + + @Builder + private static class ProjectBatchProcessingParameters { + private int currentPageNumber; + private int currentIndex; + private int numberOfPages; + private boolean repositoryHasMoreData; + private boolean shouldStartANewBatchProcess; + private List currentProjectBatch; + } + + /** + * Retrieves the next project input data for processing. + */ + public ProjectInputDTO getNextProjectInputData() { + if (this.processingParameters.shouldStartANewBatchProcess) { + initializeANewBatchProcess(); + + if (batchContainsNoItems()) { + log.info("{} No elements found after initializing new batch process", + JobConstants.LOG_PREFIX_RECOMMENDATION); + return null; + } + } + + if (currentProjectBatchIsProcessed()) { + setNextProjectInputBatchData(); + + if (batchContainsNoItems()) { + log.info("{} Finished reading all project items", JobConstants.LOG_PREFIX_RECOMMENDATION); + return null; + } + } + + ProjectInputDTO nextProjectInputDTO = this.processingParameters.currentProjectBatch + .get(this.processingParameters.currentIndex); + this.processingParameters.currentIndex++; + return nextProjectInputDTO; + } + + /** + * Resets batch processing parameters for the next job execution. + */ + public void initializeBatchProcessingParametersForTheNextProcess() { + this.processingParameters = ProjectBatchProcessingParameters.builder().currentPageNumber(0).currentIndex(0) + .numberOfPages(0).repositoryHasMoreData(false).shouldStartANewBatchProcess(true).build(); + } + + @PostConstruct + private void initializeBatchProcessingParameters() { + initializeBatchProcessingParametersForTheNextProcess(); + } + + private boolean batchContainsNoItems() { + return CollectionUtils.isEmpty(this.processingParameters.currentProjectBatch); + } + + private boolean currentProjectBatchIsProcessed() { + return this.processingParameters.currentIndex == this.processingParameters.currentProjectBatch.size(); + } + + private void initializeANewBatchProcess() { + Page projectPage = getNextProjectPage(); + HierarchyLevel projectHierarchyLevel = hierarchyLevelServiceImpl.getProjectHierarchyLevel(); + + this.processingParameters = ProjectBatchProcessingParameters.builder().currentPageNumber(0).currentIndex(0) + .numberOfPages(projectPage.getTotalPages()).repositoryHasMoreData(projectPage.hasNext()) + .shouldStartANewBatchProcess(false) + .currentProjectBatch(constructProjectInputDTOList(projectPage, projectHierarchyLevel)).build(); + } + + private void setNextProjectInputBatchData() { + if (this.processingParameters.repositoryHasMoreData) { + this.processingParameters.currentPageNumber++; + + Page projectPage = getNextProjectPage(); + HierarchyLevel projectHierarchyLevel = hierarchyLevelServiceImpl.getProjectHierarchyLevel(); + + this.processingParameters.currentProjectBatch = constructProjectInputDTOList(projectPage, + projectHierarchyLevel); + this.processingParameters.repositoryHasMoreData = projectPage.hasNext(); + this.processingParameters.currentIndex = 0; + } else { + this.processingParameters.currentProjectBatch = Collections.emptyList(); + } + } + + private Page getNextProjectPage() { + return projectBasicConfigRepository.findByKanbanAndProjectOnHold(false, false, + PageRequest.of(this.processingParameters.currentPageNumber, + recommendationCalculationConfig.getBatching().getChunkSize())); + } + + private List constructProjectInputDTOList(Page projectPage, + HierarchyLevel projectHierarchyLevel) { + return projectPage.stream().filter(project -> project.getId() != null && project.getProjectNodeId() != null) + .map(project -> ProjectInputDTO.builder().name(project.getProjectDisplayName()) + .nodeId(project.getProjectNodeId()).basicProjectConfigId(String.valueOf(project.getId())) + .hierarchyLevel(projectHierarchyLevel.getLevel()) + .hierarchyLevelId(projectHierarchyLevel.getHierarchyLevelId()).sprints(Collections.emptyList()) + .build()) + .toList(); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/strategy/RecommendationCalculationJobStrategy.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/strategy/RecommendationCalculationJobStrategy.java new file mode 100644 index 000000000..e73bc8f7e --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/strategy/RecommendationCalculationJobStrategy.java @@ -0,0 +1,113 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.strategy; + +import java.util.Optional; +import java.util.concurrent.Future; + +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.integration.async.AsyncItemProcessor; +import org.springframework.batch.integration.async.AsyncItemWriter; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; + +import com.knowhow.retro.aigatewayclient.client.AiGatewayClient; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.repository.recommendation.RecommendationRepository; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.config.base.SchedulingConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.listener.RecommendationCalculationJobExecutionListener; +import com.publicissapient.kpidashboard.job.recommendationcalculation.processor.ProjectItemProcessor; +import com.publicissapient.kpidashboard.job.recommendationcalculation.reader.ProjectItemReader; +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationCalculationService; +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationProjectBatchService; +import com.publicissapient.kpidashboard.job.recommendationcalculation.writer.ProjectItemWriter; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; +import com.publicissapient.kpidashboard.job.strategy.JobStrategy; + +import lombok.RequiredArgsConstructor; + +/** + * Job strategy for recommendation calculation batch job. + * + */ +@Component +@RequiredArgsConstructor +public class RecommendationCalculationJobStrategy implements JobStrategy { + + private final JobRepository jobRepository; + private final TaskExecutor taskExecutor; + private final PlatformTransactionManager platformTransactionManager; + private final RecommendationCalculationConfig recommendationCalculationConfig; + private final RecommendationProjectBatchService projectBatchService; + private final RecommendationCalculationService recommendationCalculationService; + private final JobExecutionTraceLogService jobExecutionTraceLogService; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; + private final RecommendationRepository recommendationRepository; + private final AiGatewayClient aiGatewayClient; + + @Override + public String getJobName() { + return recommendationCalculationConfig.getName(); + } + + @Override + public Optional getSchedulingConfig() { + return Optional.of(recommendationCalculationConfig.getScheduling()); + } + + @Override + public Job getJob() { + return new JobBuilder(recommendationCalculationConfig.getName(), jobRepository).start(chunkProcessProjects()) + .listener(new RecommendationCalculationJobExecutionListener(this.projectBatchService, + this.jobExecutionTraceLogService, this.aiGatewayClient)) + .build(); + } + + private Step chunkProcessProjects() { + return new StepBuilder(String.format("%s-chunk-process", recommendationCalculationConfig.getName()), + jobRepository) + .>chunk( + recommendationCalculationConfig.getBatching().getChunkSize(), platformTransactionManager) + .reader(new ProjectItemReader(this.projectBatchService)).processor(asyncProjectProcessor()) + .writer(asyncItemWriter()).build(); + } + + private AsyncItemProcessor asyncProjectProcessor() { + AsyncItemProcessor asyncItemProcessor = new AsyncItemProcessor<>(); + asyncItemProcessor.setDelegate(new ProjectItemProcessor(this.recommendationCalculationService, + this.processorExecutionTraceLogService)); + asyncItemProcessor.setTaskExecutor(taskExecutor); + return asyncItemProcessor; + } + + private AsyncItemWriter asyncItemWriter() { + AsyncItemWriter writer = new AsyncItemWriter<>(); + writer.setDelegate( + new ProjectItemWriter(this.recommendationRepository, this.processorExecutionTraceLogService)); + return writer; + } + +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/writer/ProjectItemWriter.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/writer/ProjectItemWriter.java new file mode 100644 index 000000000..32a9f44f7 --- /dev/null +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/recommendationcalculation/writer/ProjectItemWriter.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024 Sapient Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and limitations under the + * License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.writer; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; +import org.springframework.lang.NonNull; + +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.repository.recommendation.RecommendationRepository; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.constant.JobConstants; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Spring Batch ItemWriter for persisting recommendation documents. + */ +@Slf4j +@RequiredArgsConstructor +public class ProjectItemWriter implements ItemWriter { + + private final RecommendationRepository recommendationRepository; + private final ProcessorExecutionTraceLogService processorExecutionTraceLogService; + + /** + * Writes a chunk of recommendations to the database. Filters out null items, + * saves recommendations, and updates execution trace logs. + * + * @param chunk + * the chunk of recommendations to persist (must not be null) + * @throws IllegalArgumentException + * if chunk is null + */ + @Override + public void write(@NonNull Chunk chunk) { + // Filter out nulls + List itemsToSave = chunk.getItems().stream().filter(Objects::nonNull) + .collect(Collectors.toList()); + + log.info("{} Received chunk items for inserting into database with size: {} recommendations from {} projects", + JobConstants.LOG_PREFIX_RECOMMENDATION, itemsToSave.size(), chunk.size()); + + if (!itemsToSave.isEmpty()) { + // Save recommendations + recommendationRepository.saveAll(itemsToSave); + log.info("{} Successfully saved {} recommendation documents", + JobConstants.LOG_PREFIX_RECOMMENDATION, itemsToSave.size()); + + // Save execution trace logs per project + itemsToSave.forEach(this::saveProjectExecutionTraceLog); + } + } + + /** + * Creates or updates execution trace log for a project. + * + * @param recommendation + * The recommendation containing project metadata + */ + private void saveProjectExecutionTraceLog(RecommendationsActionPlan recommendation) { + String projectId = recommendation.getBasicProjectConfigId(); + processorExecutionTraceLogService.upsertTraceLog(JobConstants.JOB_RECOMMENDATION_CALCULATION, projectId, true, + null); + } +} diff --git a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/shared/dto/ProjectInputDTO.java b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/shared/dto/ProjectInputDTO.java index 135b76c81..c7a02bac9 100644 --- a/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/shared/dto/ProjectInputDTO.java +++ b/ai-data-processor/src/main/java/com/publicissapient/kpidashboard/job/shared/dto/ProjectInputDTO.java @@ -18,9 +18,11 @@ import java.util.List; +import com.publicissapient.kpidashboard.common.shared.enums.ProjectDeliveryMethodology; + import lombok.Builder; @Builder public record ProjectInputDTO(int hierarchyLevel, String hierarchyLevelId, String name, String nodeId, - List sprints) { + String basicProjectConfigId, ProjectDeliveryMethodology deliveryMethodology, List sprints) { } diff --git a/ai-data-processor/src/main/resources/application-local.yml b/ai-data-processor/src/main/resources/application-local.yml index 1ad36acd3..ab7e1bf89 100644 --- a/ai-data-processor/src/main/resources/application-local.yml +++ b/ai-data-processor/src/main/resources/application-local.yml @@ -9,6 +9,16 @@ spring: max-size: 8 queue-capacity: 100 -custom-api-config: +knowhow-api-config: base-url: - api-key: \ No newline at end of file + api-key: + +ai-gateway-config: + base-url: + audience: + default-ai-provider: + +m2mauth: + secret: + duration: + issuer-service-id: diff --git a/ai-data-processor/src/main/resources/application.yml b/ai-data-processor/src/main/resources/application.yml index aaca3a7d3..9f0b21ee4 100644 --- a/ai-data-processor/src/main/resources/application.yml +++ b/ai-data-processor/src/main/resources/application.yml @@ -69,6 +69,8 @@ knowhow-api-config: endpoints: kpi-integration-values: path: /kpiIntegrationValues + kpi-integration-values-kanban: + path: /kpi-integration-values/kanban rate-limiting: max-concurrent-calls: 10 retry-policy: @@ -125,6 +127,41 @@ jobs: cron: 0 0 0 ? * FRI batching: chunk-size: 10 + recommendation-calculation: + name: recommendation-calculation + batching: + chunk-size: 50 + scheduling: + cron: ${RECOMMENDATION_CALC_CRON:0 0 2 * * SAT} + calculation-config: + enabled-persona: PROJECT_ADMIN + kpi-list: + - kpi39 + - kpi46 + - kpi70 + - kpi172 + - kpi17 + - kpi8 + - kpi27 + - kpi156 + - kpi14 + - kpi37 + - kpi34 + - kpi111 + - kpi16 + - kpi42 + - kpi5 + - kpi82 + - kpi149 + - kpi113 + - kpi168 + - kpi73 + - kpi40 + - kpi164 + - kpi126 + - kpi35 + - kpi72 + - kpi38 mongo: ttl-index: @@ -146,4 +183,28 @@ mongo: ttl-field: calculationDate expiration: 180 time-unit: DAYS - sort-direction: ASC \ No newline at end of file + sort-direction: ASC + recommendation-calculation: + collection-name: recommendations_action_plan + ttl-field: expiresOn + expiration: 180 + time-unit: DAYS + sort-direction: ASC + job-execution-trace: + collection-name: job_execution_trace_log + ttl-field: executionStartedAt + expiration: 180 + time-unit: DAYS + sort-direction: ASC + +# M2M Authentication for AI Gateway Client +m2mauth: + secret: ${AUTH_SECRET} + duration: 7200 + issuer-service-id: ${AUTH_ISSUER_SERVICE_ID} + +# AI Gateway Configuration +ai-gateway-config: + audience: ${AI_GATEWAY_AUDIENCE} + base-url: ${AI_GATEWAY_BASE_URL} + default-ai-provider: ${AI_GATEWAY_DEFAULT_PROVIDER:openai} \ No newline at end of file diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AIUsageStatisticsServiceTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AIUsageStatisticsServiceTest.java index 1da98b23a..4dbd2efaa 100644 --- a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AIUsageStatisticsServiceTest.java +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AIUsageStatisticsServiceTest.java @@ -18,8 +18,8 @@ import com.publicissapient.kpidashboard.client.shareddataservice.SharedDataServiceClient; import com.publicissapient.kpidashboard.exception.InternalServerErrorException; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsageSummary; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.mapper.AIUsageStatisticsMapper; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.enums.AIUsageAggregationType; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.model.AIUsageStatistics; @@ -62,7 +62,7 @@ void saveAIUsageStatistics_successful() { AIUsageAggregationType.TOTAL ); - PagedAIUsagePerOrgLevel response = new PagedAIUsagePerOrgLevel( + AIUsagePerOrgLevel response = new AIUsagePerOrgLevel( "account", levelName, Instant.now(), diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AccountBatchServiceTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AccountBatchServiceTest.java index 6c924bc5c..86f7fa342 100644 --- a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AccountBatchServiceTest.java +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/aiusagestatistics/service/AccountBatchServiceTest.java @@ -18,7 +18,7 @@ import com.publicissapient.kpidashboard.common.model.application.AccountHierarchy; import com.publicissapient.kpidashboard.common.repository.application.AccountHierarchyRepository; -import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.PagedAIUsagePerOrgLevel; +import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.dto.AIUsagePerOrgLevel; import com.publicissapient.kpidashboard.job.aiusagestatisticscollector.service.AccountBatchService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,7 +56,7 @@ void initializeBatchProcessingParametersForTheNextProcess_loadsAccountsAndSorts( service.initializeBatchProcessingParametersForTheNextProcess(); - PagedAIUsagePerOrgLevel page = service.getNextAccountPage(); + AIUsagePerOrgLevel page = service.getNextAccount(); assertNotNull(page); assertEquals("AccountA", page.levelName()); assertEquals(1, page.currentPage()); @@ -65,35 +65,35 @@ void initializeBatchProcessingParametersForTheNextProcess_loadsAccountsAndSorts( } @Test - void getNextAccountPage_returnsAccountsOneByOne() { + void getNextAccount_returnsAccountsOneByOne() { List accounts = List.of( createAccount("1", "Account1"), createAccount("2", "Account2") ); when(repository.findDistinctByLabel("acc")).thenReturn(accounts); - PagedAIUsagePerOrgLevel page1 = service.getNextAccountPage(); + AIUsagePerOrgLevel page1 = service.getNextAccount(); assertNotNull(page1); assertEquals("Account1", page1.levelName()); - PagedAIUsagePerOrgLevel page2 = service.getNextAccountPage(); + AIUsagePerOrgLevel page2 = service.getNextAccount(); assertNotNull(page2); assertEquals("Account2", page2.levelName()); - PagedAIUsagePerOrgLevel page3 = service.getNextAccountPage(); + AIUsagePerOrgLevel page3 = service.getNextAccount(); assertNull(page3); } @Test - void getNextAccountPage_emptyRepository_returnsNull() { + void getNextAccount_emptyRepository_returnsNull() { when(repository.findDistinctByLabel("acc")).thenReturn(List.of()); - PagedAIUsagePerOrgLevel page = service.getNextAccountPage(); + AIUsagePerOrgLevel page = service.getNextAccount(); assertNull(page); } @Test - void getNextAccountPage_multipleCalls_iteratesCorrectly() { + void getNextAccount_multipleCalls_iteratesCorrectly() { List accounts = List.of( createAccount("1", "Account1"), createAccount("2", "Account2"), @@ -101,10 +101,10 @@ void getNextAccountPage_multipleCalls_iteratesCorrectly() { ); when(repository.findDistinctByLabel("acc")).thenReturn(accounts); - PagedAIUsagePerOrgLevel page1 = service.getNextAccountPage(); - PagedAIUsagePerOrgLevel page2 = service.getNextAccountPage(); - PagedAIUsagePerOrgLevel page3 = service.getNextAccountPage(); - PagedAIUsagePerOrgLevel page4 = service.getNextAccountPage(); + AIUsagePerOrgLevel page1 = service.getNextAccount(); + AIUsagePerOrgLevel page2 = service.getNextAccount(); + AIUsagePerOrgLevel page3 = service.getNextAccount(); + AIUsagePerOrgLevel page4 = service.getNextAccount(); assertEquals("Account1", page1.levelName()); assertEquals("Account2", page2.levelName()); diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationServiceTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationServiceTest.java index 37b1bb1b1..e7de0de23 100644 --- a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationServiceTest.java +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/kpimaturitycalculation/service/KpiMaturityCalculationServiceTest.java @@ -24,6 +24,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.util.Collections; @@ -55,7 +56,6 @@ import com.publicissapient.kpidashboard.common.repository.application.KpiCategoryMappingRepository; import com.publicissapient.kpidashboard.common.repository.application.KpiMasterCustomRepository; import com.publicissapient.kpidashboard.common.repository.projection.BasicKpiMasterProjection; -import com.publicissapient.kpidashboard.common.shared.enums.ProjectDeliveryMethodology; import com.publicissapient.kpidashboard.job.kpimaturitycalculation.config.CalculationConfig; import com.publicissapient.kpidashboard.job.kpimaturitycalculation.config.KpiMaturityCalculationConfig; import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; @@ -80,9 +80,6 @@ class KpiMaturityCalculationServiceTest { @Mock private CalculationConfig calculationConfig; - @Mock - private CalculationConfig.DataPoints dataPoints; - @Mock private CalculationConfig.Maturity maturity; @@ -163,6 +160,9 @@ void when_NoKpiElementContainsANumericOverallMaturity_Expect_MaturityIsNotCalcul @Test void when_RequestIsValid_Expect_KpiMaturityIsComputedAsExpected() { initializeKpiMaturityCalculationConfigurations(); + CalculationConfig.DataPoints mockDataPoints = mock(CalculationConfig.DataPoints.class); + when(calculationConfig.getDataPoints()).thenReturn(mockDataPoints); + when(mockDataPoints.getCount()).thenReturn(5); Set expectedKpiIdsAfterMaturityCalculation = Set.of("kpi1", "kpi3", "kpi4", "kpi6", "kpi7", "kpi10", "kpi11"); @@ -214,8 +214,7 @@ private void initializeKpiMaturityCalculationConfigurations() { } private void initializeKpisUsedForMaturityCalculation() { - when(kpiMasterCustomRepository - .findByDeliveryMethodologyTypeSupportingMaturityCalculation(ProjectDeliveryMethodology.SCRUM)) + when(kpiMasterCustomRepository.findKpisSupportingMaturityCalculation()) .thenReturn(createMockKpiMasterProjections()); when(kpiCategoryMappingRepository.findAllByKpiIdIn(anySet())).thenReturn(createMockKpiCategoryMapping()); } @@ -223,10 +222,8 @@ private void initializeKpisUsedForMaturityCalculation() { private void initializeCalculationConfig() { // Setup configuration mocks when(kpiMaturityCalculationConfig.getCalculationConfig()).thenReturn(calculationConfig); - when(calculationConfig.getDataPoints()).thenReturn(dataPoints); when(calculationConfig.getAllConfiguredCategories()).thenReturn(Set.of("quality", "value", "dora", "speed")); when(calculationConfig.getMaturity()).thenReturn(maturity); - when(dataPoints.getCount()).thenReturn(5); when(maturity.getWeights()).thenReturn(Map.of("quality", 0.25, "value", 0.25, "dora", 0.25, "speed", 0.25)); when(calculationConfig.getConfigValidationErrors()).thenReturn(Collections.emptySet()); } @@ -256,23 +253,28 @@ private static List createMockKpiCategoryMapping() { } private static List createMockKpiMasterProjections() { - return List.of(generateKpiMasterProjection("kpi1", "test kpi 1", "Sprints", null, "Jira"), - generateKpiMasterProjection("kpi2", "test kpi 2", "Sprints", null, "Zypher"), - generateKpiMasterProjection("kpi3", "test kpi 3", "Weeks", null, "Sonar"), - generateKpiMasterProjection("kpi4", "test kpi 4", "Weeks", "Dora", "Jenkins"), - generateKpiMasterProjection("kpi5", "test kpi 5", "Weeks", "Developer", "Bitbucket"), - generateKpiMasterProjection("kpi6", "test kpi 6", "Weeks", "Dora", "Jira"), - generateKpiMasterProjection("kpi7", "test kpi 7", "Weeks", null, "Jenkins"), - generateKpiMasterProjection("kpi8", "test kpi 8", "", "Iteration", "Jira"), - generateKpiMasterProjection("kpi9", "test kpi 9", "Days", "Developer", "BitBucket"), - generateKpiMasterProjection("kpi10", "test kpi 10", "Months", null, "Sonar"), - generateKpiMasterProjection("kpi11", "test kpi 11", "PIs", null, "Jira"), - generateKpiMasterProjection("kpi12", "test kpi 12", "Range", null, "Jira")); + return List.of(generateKpiMasterProjection("kpi1", "test kpi 1", "Sprints", null, "Jira", true), + generateKpiMasterProjection("kpi2", "test kpi 2", "Sprints", null, "Zypher", false), + generateKpiMasterProjection("kpi3", "test kpi 3", "Weeks", null, "Sonar", false), + generateKpiMasterProjection("kpi4", "test kpi 4", "Weeks", "Dora", "Jenkins", true), + generateKpiMasterProjection("kpi5", "test kpi 5", "Weeks", "Developer", "Bitbucket", true), + generateKpiMasterProjection("kpi6", "test kpi 6", "Weeks", "Dora", "Jira", true), + generateKpiMasterProjection("kpi7", "test kpi 7", "Weeks", null, "Jenkins", false), + generateKpiMasterProjection("kpi8", "test kpi 8", "", "Iteration", "Jira", false), + generateKpiMasterProjection("kpi9", "test kpi 9", "Days", "Developer", "BitBucket", true), + generateKpiMasterProjection("kpi10", "test kpi 10", "Months", null, "Sonar", false), + generateKpiMasterProjection("kpi11", "test kpi 11", "PIs", null, "Jira", true), + generateKpiMasterProjection("kpi12", "test kpi 12", "Range", null, "Jira", true)); } private static BasicKpiMasterProjection generateKpiMasterProjection(String kpiId, String kpiName, String xAxisLabel, - String kpiCategory, String kpiSource) { + String kpiCategory, String kpiSource, boolean isKanban) { return new BasicKpiMasterProjection() { + @Override + public boolean isKanban() { + return isKanban; + } + @Override public String getKpiId() { return kpiId; diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestratorTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestratorTest.java index 68d724730..245fdce0d 100644 --- a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestratorTest.java +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/orchestrator/JobOrchestratorTest.java @@ -39,6 +39,8 @@ import java.util.Map; import java.util.Set; +import com.publicissapient.kpidashboard.common.model.tracelog.JobExecutionTraceLog; +import com.publicissapient.kpidashboard.common.service.JobExecutionTraceLogService; import org.bson.types.ObjectId; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,9 +58,8 @@ import org.springframework.test.util.ReflectionTestUtils; import com.publicissapient.kpidashboard.common.constant.ProcessorType; -import com.publicissapient.kpidashboard.common.model.ProcessorExecutionTraceLog; import com.publicissapient.kpidashboard.common.model.generic.Processor; -import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogServiceImpl; +import com.publicissapient.kpidashboard.common.constant.ProcessorConstants; import com.publicissapient.kpidashboard.exception.ConcurrentJobExecutionException; import com.publicissapient.kpidashboard.exception.InternalServerErrorException; import com.publicissapient.kpidashboard.exception.JobNotEnabledException; @@ -83,7 +84,7 @@ class JobOrchestratorTest { private AiDataProcessorRepository aiDataProcessorRepository; @Mock - private ProcessorExecutionTraceLogServiceImpl processorExecutionTraceLogServiceImpl; + private JobExecutionTraceLogService jobExecutionTraceLogService; @InjectMocks private JobOrchestrator jobOrchestrator; @@ -546,7 +547,7 @@ void when_RunJobWithValidRegisteredEnabledJob_Then_ExecutesJobAndReturnsExecutio // Arrange String jobName = "testJob"; AiDataProcessor processor = createAiDataProcessor(jobName, true); - ProcessorExecutionTraceLog traceLog = createProcessorExecutionTraceLog(jobName); + JobExecutionTraceLog traceLog = createProcessorExecutionTraceLog(ProcessorConstants.AI_DATA,jobName); JobStrategy mockJobStrategy = mock(JobStrategy.class); Job mockJob = mock(Job.class); @@ -556,9 +557,9 @@ void when_RunJobWithValidRegisteredEnabledJob_Then_ExecutesJobAndReturnsExecutio when(aiDataJobRegistry.getJobStrategyMap()).thenReturn(jobStrategyMap); when(aiDataProcessorRepository.findByProcessorName(jobName)).thenReturn(processor); - when(processorExecutionTraceLogServiceImpl.createNewProcessorJobExecution(jobName)).thenReturn(traceLog); - when(processorExecutionTraceLogServiceImpl.findLastExecutionTraceLogsByProcessorName(jobName, 1)) - .thenReturn(Collections.emptyList()); + when(jobExecutionTraceLogService.createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName)).thenReturn(traceLog); + when(jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, jobName)) + .thenReturn(false); when(aiDataJobRegistry.getJobStrategy(jobName)).thenReturn(mockJobStrategy); when(mockJobStrategy.getJob()).thenReturn(mockJob); @@ -574,7 +575,7 @@ void when_RunJobWithValidRegisteredEnabledJob_Then_ExecutesJobAndReturnsExecutio assertNotNull(result.startedAt()); verify(jobLauncher).run(eq(mockJob), any(JobParameters.class)); - verify(processorExecutionTraceLogServiceImpl).createNewProcessorJobExecution(jobName); + verify(jobExecutionTraceLogService).createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName); } @Test @@ -590,7 +591,7 @@ void when_RunJobWithUnregisteredJob_Then_ThrowsResourceNotFoundException() throw assertTrue(exception.getMessage().contains("Job 'unregisteredJob' is not registered")); verify(jobLauncher, never()).run(any(Job.class), any(JobParameters.class)); - verify(processorExecutionTraceLogServiceImpl, never()).createNewProcessorJobExecution(anyString()); + verify(jobExecutionTraceLogService, never()).createProcessorJobExecution(anyString(),anyString()); } @Test @@ -612,7 +613,7 @@ void when_RunJobWithDisabledJob_Then_ThrowsJobNotEnabledException() throws JobIn assertTrue(exception.getMessage().contains("Job 'disabledJob' did not run because is disabled")); verify(jobLauncher, never()).run(any(Job.class), any(JobParameters.class)); - verify(processorExecutionTraceLogServiceImpl, never()).createNewProcessorJobExecution(anyString()); + verify(jobExecutionTraceLogService, never()).createProcessorJobExecution(anyString(), anyString()); } @Test @@ -620,8 +621,8 @@ void when_RunJobWithAlreadyRunningJob_Then_ThrowsJobIsAlreadyRunningException() // Arrange String jobName = "runningJob"; AiDataProcessor processor = createAiDataProcessor(jobName, true); - ProcessorExecutionTraceLog runningTraceLog = createProcessorExecutionTraceLog(jobName); - runningTraceLog.setExecutionEndedAt(0L); // Indicates ongoing execution + JobExecutionTraceLog runningTraceLog = createProcessorExecutionTraceLog(ProcessorConstants.AI_DATA,jobName); + runningTraceLog.setExecutionEndedAt(Instant.EPOCH); runningTraceLog.setExecutionOngoing(true); JobStrategy mockJobStrategy = mock(JobStrategy.class); @@ -631,15 +632,15 @@ void when_RunJobWithAlreadyRunningJob_Then_ThrowsJobIsAlreadyRunningException() when(aiDataJobRegistry.getJobStrategyMap()).thenReturn(jobStrategyMap); when(aiDataProcessorRepository.findByProcessorName(jobName)).thenReturn(processor); - when(processorExecutionTraceLogServiceImpl.findLastExecutionTraceLogsByProcessorName(jobName, 1)) - .thenReturn(List.of(runningTraceLog)); + when(jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, jobName)) + .thenReturn(true); // Act & Assert ConcurrentJobExecutionException exception = assertThrows(ConcurrentJobExecutionException.class, () -> jobOrchestrator.runJob(jobName)); assertTrue(exception.getMessage().contains("Job 'runningJob' is already running")); verify(jobLauncher, never()).run(any(Job.class), any(JobParameters.class)); - verify(processorExecutionTraceLogServiceImpl, never()).createNewProcessorJobExecution(anyString()); + verify(jobExecutionTraceLogService, never()).createProcessorJobExecution(anyString(), anyString()); } @Test @@ -647,7 +648,7 @@ void when_RunJobAndJobLauncherThrowsException_Then_UpdatesTraceLogAndThrowsInter // Arrange String jobName = "failingJob"; AiDataProcessor processor = createAiDataProcessor(jobName, true); - ProcessorExecutionTraceLog traceLog = createProcessorExecutionTraceLog(jobName); + JobExecutionTraceLog traceLog = createProcessorExecutionTraceLog(ProcessorConstants.AI_DATA,jobName); RuntimeException jobLauncherException = new RuntimeException("Job execution failed"); JobStrategy mockJobStrategy = mock(JobStrategy.class); @@ -658,9 +659,9 @@ void when_RunJobAndJobLauncherThrowsException_Then_UpdatesTraceLogAndThrowsInter when(aiDataJobRegistry.getJobStrategyMap()).thenReturn(jobStrategyMap); when(aiDataProcessorRepository.findByProcessorName(jobName)).thenReturn(processor); - when(processorExecutionTraceLogServiceImpl.createNewProcessorJobExecution(jobName)).thenReturn(traceLog); - when(processorExecutionTraceLogServiceImpl.findLastExecutionTraceLogsByProcessorName(jobName, 1)) - .thenReturn(Collections.emptyList()); + when(jobExecutionTraceLogService.createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName)).thenReturn(traceLog); + when(jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, jobName)) + .thenReturn(false); when(aiDataJobRegistry.getJobStrategy(jobName)).thenReturn(mockJobStrategy); when(mockJobStrategy.getJob()).thenReturn(mockJob); when(jobLauncher.run(any(Job.class), any(JobParameters.class))).thenThrow(jobLauncherException); @@ -671,10 +672,10 @@ void when_RunJobAndJobLauncherThrowsException_Then_UpdatesTraceLogAndThrowsInter assertTrue(exception.getMessage().contains("Encountered unexpected error while trying to run job with name 'failingJob'")); // Verify trace log was updated with error details - ArgumentCaptor traceLogCaptor = ArgumentCaptor.forClass(ProcessorExecutionTraceLog.class); - verify(processorExecutionTraceLogServiceImpl).saveAiDataProcessorExecutions(traceLogCaptor.capture()); + ArgumentCaptor traceLogCaptor = ArgumentCaptor.forClass(JobExecutionTraceLog.class); + verify(jobExecutionTraceLogService).updateJobExecution(traceLogCaptor.capture()); - ProcessorExecutionTraceLog savedTraceLog = traceLogCaptor.getValue(); + JobExecutionTraceLog savedTraceLog = traceLogCaptor.getValue(); assertFalse(savedTraceLog.isExecutionSuccess()); assertNotNull(savedTraceLog.getErrorDetailList()); assertFalse(savedTraceLog.getErrorDetailList().isEmpty()); @@ -686,7 +687,7 @@ void when_RunJobWithValidJobParameters_Then_PassesCorrectParametersToJobLauncher // Arrange String jobName = "parameterTestJob"; AiDataProcessor processor = createAiDataProcessor(jobName, true); - ProcessorExecutionTraceLog traceLog = createProcessorExecutionTraceLog(jobName); + JobExecutionTraceLog traceLog = createProcessorExecutionTraceLog(ProcessorConstants.AI_DATA,jobName); JobStrategy mockJobStrategy = mock(JobStrategy.class); Job mockJob = mock(Job.class); @@ -696,9 +697,9 @@ void when_RunJobWithValidJobParameters_Then_PassesCorrectParametersToJobLauncher when(aiDataJobRegistry.getJobStrategyMap()).thenReturn(jobStrategyMap); when(aiDataProcessorRepository.findByProcessorName(jobName)).thenReturn(processor); - when(processorExecutionTraceLogServiceImpl.createNewProcessorJobExecution(jobName)).thenReturn(traceLog); - when(processorExecutionTraceLogServiceImpl.findLastExecutionTraceLogsByProcessorName(jobName, 1)) - .thenReturn(Collections.emptyList()); + when(jobExecutionTraceLogService.createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName)).thenReturn(traceLog); + when(jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, jobName)) + .thenReturn(false); when(aiDataJobRegistry.getJobStrategy(jobName)).thenReturn(mockJobStrategy); when(mockJobStrategy.getJob()).thenReturn(mockJob); @@ -768,9 +769,9 @@ void when_RunJobSuccessfully_Then_ReturnsCorrectExecutionResponseFields() { AiDataProcessor processor = createAiDataProcessor(jobName, true); processor.setId(processorId); - ProcessorExecutionTraceLog traceLog = createProcessorExecutionTraceLog(jobName); + JobExecutionTraceLog traceLog = createProcessorExecutionTraceLog(ProcessorConstants.AI_DATA,jobName); traceLog.setId(executionId); - traceLog.setExecutionStartedAt(executionStartTime); + traceLog.setExecutionStartedAt(Instant.ofEpochMilli(executionStartTime)); JobStrategy mockJobStrategy = mock(JobStrategy.class); Job mockJob = mock(Job.class); @@ -780,9 +781,9 @@ void when_RunJobSuccessfully_Then_ReturnsCorrectExecutionResponseFields() { when(aiDataJobRegistry.getJobStrategyMap()).thenReturn(jobStrategyMap); when(aiDataProcessorRepository.findByProcessorName(jobName)).thenReturn(processor); - when(processorExecutionTraceLogServiceImpl.createNewProcessorJobExecution(jobName)).thenReturn(traceLog); - when(processorExecutionTraceLogServiceImpl.findLastExecutionTraceLogsByProcessorName(jobName, 1)) - .thenReturn(Collections.emptyList()); + when(jobExecutionTraceLogService.createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName)).thenReturn(traceLog); + when(jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, jobName)) + .thenReturn(false); when(aiDataJobRegistry.getJobStrategy(jobName)).thenReturn(mockJobStrategy); when(mockJobStrategy.getJob()).thenReturn(mockJob); @@ -802,7 +803,7 @@ void when_RunJobAndJobLauncherThrowsCheckedException_Then_HandlesExceptionCorrec // Arrange String jobName = "checkedExceptionJob"; AiDataProcessor processor = createAiDataProcessor(jobName, true); - ProcessorExecutionTraceLog traceLog = createProcessorExecutionTraceLog(jobName); + JobExecutionTraceLog traceLog = createProcessorExecutionTraceLog(ProcessorConstants.AI_DATA,jobName); RuntimeException runtimeException = new RuntimeException("Runtime exception occurred"); JobStrategy mockJobStrategy = mock(JobStrategy.class); @@ -813,9 +814,9 @@ void when_RunJobAndJobLauncherThrowsCheckedException_Then_HandlesExceptionCorrec when(aiDataJobRegistry.getJobStrategyMap()).thenReturn(jobStrategyMap); when(aiDataProcessorRepository.findByProcessorName(jobName)).thenReturn(processor); - when(processorExecutionTraceLogServiceImpl.createNewProcessorJobExecution(jobName)).thenReturn(traceLog); - when(processorExecutionTraceLogServiceImpl.findLastExecutionTraceLogsByProcessorName(jobName, 1)) - .thenReturn(Collections.emptyList()); + when(jobExecutionTraceLogService.createProcessorJobExecution(ProcessorConstants.AI_DATA, jobName)).thenReturn(traceLog); + when(jobExecutionTraceLogService.isJobCurrentlyRunning(ProcessorConstants.AI_DATA, jobName)) + .thenReturn(false); when(aiDataJobRegistry.getJobStrategy(jobName)).thenReturn(mockJobStrategy); when(mockJobStrategy.getJob()).thenReturn(mockJob); when(jobLauncher.run(any(Job.class), any(JobParameters.class))).thenThrow(runtimeException); @@ -826,10 +827,10 @@ void when_RunJobAndJobLauncherThrowsCheckedException_Then_HandlesExceptionCorrec assertTrue(exception.getMessage().contains("Encountered unexpected error while trying to run job with name 'checkedExceptionJob'")); // Verify error details contain the original exception message - ArgumentCaptor traceLogCaptor = ArgumentCaptor.forClass(ProcessorExecutionTraceLog.class); - verify(processorExecutionTraceLogServiceImpl).saveAiDataProcessorExecutions(traceLogCaptor.capture()); + ArgumentCaptor traceLogCaptor = ArgumentCaptor.forClass(JobExecutionTraceLog.class); + verify(jobExecutionTraceLogService).updateJobExecution(traceLogCaptor.capture()); - ProcessorExecutionTraceLog savedTraceLog = traceLogCaptor.getValue(); + JobExecutionTraceLog savedTraceLog = traceLogCaptor.getValue(); assertTrue(savedTraceLog.getErrorDetailList().get(0).getError().contains("Runtime exception occurred")); } @@ -843,11 +844,12 @@ private AiDataProcessor createAiDataProcessor(String processorName, boolean isAc } // Helper method - private ProcessorExecutionTraceLog createProcessorExecutionTraceLog(String processorName) { - ProcessorExecutionTraceLog traceLog = new ProcessorExecutionTraceLog(); + private JobExecutionTraceLog createProcessorExecutionTraceLog(String processorName, String jobName) { + JobExecutionTraceLog traceLog = new JobExecutionTraceLog(); traceLog.setId(new ObjectId()); traceLog.setProcessorName(processorName); - traceLog.setExecutionStartedAt(Instant.now().toEpochMilli()); + traceLog.setJobName(jobName); + traceLog.setExecutionStartedAt(Instant.now()); traceLog.setExecutionSuccess(true); return traceLog; } diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/CalculationConfigTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/CalculationConfigTest.java new file mode 100644 index 000000000..ea2e55568 --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/config/CalculationConfigTest.java @@ -0,0 +1,328 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.config; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Persona; + +class CalculationConfigTest { + + private CalculationConfig calculationConfig; + + @BeforeEach + void setUp() { + calculationConfig = new CalculationConfig(); + } + + @Test + void when_NoEnabledPersonaConfigured_Then_ValidationErrorAdded() { + // Arrange + calculationConfig.setEnabledPersona(null); + calculationConfig.setKpiList(List.of("kpi14", "kpi82")); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + Set errors = calculationConfig.getConfigValidationErrors(); + assertFalse(errors.isEmpty()); + assertTrue(errors.contains("No enabled persona configured for recommendation calculation")); + } + + @Test + void when_NoKpiListConfigured_Then_ValidationErrorAdded() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(null); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + Set errors = calculationConfig.getConfigValidationErrors(); + assertFalse(errors.isEmpty()); + assertTrue(errors.contains("No KPI list configured for recommendation calculation")); + } + + @Test + void when_EmptyKpiListConfigured_Then_ValidationErrorAdded() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(Collections.emptyList()); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + Set errors = calculationConfig.getConfigValidationErrors(); + assertFalse(errors.isEmpty()); + assertTrue(errors.contains("No KPI list configured for recommendation calculation")); + } + + @Test + void when_BothPersonaAndKpiListMissing_Then_BothValidationErrorsAdded() { + // Arrange + calculationConfig.setEnabledPersona(null); + calculationConfig.setKpiList(null); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + Set errors = calculationConfig.getConfigValidationErrors(); + assertEquals(2, errors.size()); + assertTrue(errors.contains("No enabled persona configured for recommendation calculation")); + assertTrue(errors.contains("No KPI list configured for recommendation calculation")); + } + + @Test + void when_ValidPersonaAndKpiListConfigured_Then_NoValidationErrors() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(List.of("kpi14", "kpi82", "kpi111")); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_ExecutiveSponsorPersonaConfigured_Then_NoValidationErrors() { + // Arrange + calculationConfig.setEnabledPersona(Persona.EXECUTIVE_SPONSOR); + calculationConfig.setKpiList(List.of("kpi14", "kpi82")); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_ScrumMasterPersonaConfigured_Then_NoValidationErrors() { + // Arrange + calculationConfig.setEnabledPersona(Persona.SCRUM_MASTER); + calculationConfig.setKpiList(List.of("kpi14", "kpi82")); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_Complete26KpiListConfigured_Then_NoValidationErrors() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(List.of( + "kpi14", "kpi82", "kpi111", "kpi35", "kpi34", + "kpi37", "kpi28", "kpi36", "kpi126", "kpi42", + "kpi16", "kpi17", "kpi38", "kpi27", "kpi72", + "kpi84", "kpi11", "kpi62", "kpi64", "kpi67", + "kpi65", "kpi157", "kpi158", "kpi116", "kpi118", + "kpi997" + )); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_SingleKpiInList_Then_NoValidationErrors() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(List.of("kpi14")); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_ValidationErrorsExist_Then_ReturnsUnmodifiableSet() { + // Arrange + calculationConfig.setEnabledPersona(null); + calculationConfig.setKpiList(null); + calculationConfig.validateConfiguration(); + + // Act + Set errors = calculationConfig.getConfigValidationErrors(); + + // Assert + assertThrows(UnsupportedOperationException.class, () -> { + errors.add("Should not be able to modify"); + }); + } + + @Test + void when_GetEnabledPersona_Then_ReturnsConfiguredPersona() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + + // Act + Persona result = calculationConfig.getEnabledPersona(); + + // Assert + assertNotNull(result); + assertEquals(Persona.ENGINEERING_LEAD, result); + } + + @Test + void when_GetKpiList_Then_ReturnsConfiguredList() { + // Arrange + List kpiList = List.of("kpi14", "kpi82", "kpi111"); + calculationConfig.setKpiList(kpiList); + + // Act + List result = calculationConfig.getKpiList(); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.contains("kpi14")); + assertTrue(result.contains("kpi82")); + assertTrue(result.contains("kpi111")); + } + + @Test + void when_NoPersonaSet_Then_ReturnsNull() { + // Arrange - Don't set persona + + // Act + Persona result = calculationConfig.getEnabledPersona(); + + // Assert + assertNull(result); + } + + @Test + void when_NoKpiListSet_Then_ReturnsNull() { + // Arrange - Don't set KPI list + + // Act + List result = calculationConfig.getKpiList(); + + // Assert + assertNull(result); + } + + @Test + void when_MultipleValidationCallsWithSameErrors_Then_ErrorsNotDuplicated() { + // Arrange + calculationConfig.setEnabledPersona(null); + calculationConfig.setKpiList(null); + + // Act + calculationConfig.validateConfiguration(); + calculationConfig.validateConfiguration(); + + // Assert + Set errors = calculationConfig.getConfigValidationErrors(); + // Errors should still be only 2 (persona and kpi list), not 4 + assertEquals(2, errors.size()); + } + + @Test + void when_ConfigurationFixedAfterValidation_Then_ValidationPassesOnRetry() { + // Arrange + calculationConfig.setEnabledPersona(null); + calculationConfig.setKpiList(null); + calculationConfig.validateConfiguration(); + assertEquals(2, calculationConfig.getConfigValidationErrors().size()); + + // Act - Fix configuration + calculationConfig = new CalculationConfig(); // Reset to clear errors + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(List.of("kpi14")); + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_AllPersonaEnumValuesUsed_Then_AllValidate() { + // Test all available persona values + List kpiList = List.of("kpi14", "kpi82"); + + for (Persona persona : Persona.values()) { + // Arrange + CalculationConfig config = new CalculationConfig(); + config.setEnabledPersona(persona); + config.setKpiList(kpiList); + + // Act + config.validateConfiguration(); + + // Assert + assertTrue(config.getConfigValidationErrors().isEmpty(), + "Persona " + persona + " should validate successfully"); + } + } + + @Test + void when_DuplicateKpisInList_Then_NoValidationErrors() { + // Arrange - List with duplicates (though not recommended) + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(List.of("kpi14", "kpi14", "kpi82")); + + // Act + calculationConfig.validateConfiguration(); + + // Assert + assertTrue(calculationConfig.getConfigValidationErrors().isEmpty()); + } + + @Test + void when_ConfigValidatorInterfaceImplemented_Then_MethodsAccessible() { + // Arrange + calculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + calculationConfig.setKpiList(List.of("kpi14")); + + // Act + calculationConfig.validateConfiguration(); + Set errors = calculationConfig.getConfigValidationErrors(); + + // Assert - Methods from ConfigValidator interface should be callable + assertNotNull(errors); + assertTrue(errors.isEmpty()); + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParserTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParserTest.java new file mode 100644 index 000000000..122209c1d --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/parser/BatchRecommendationResponseParserTest.java @@ -0,0 +1,495 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.parser; + +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 java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.knowhow.retro.aigatewayclient.client.response.chat.ChatGenerationResponseDTO; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Recommendation; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Severity; + +@DisplayName("BatchRecommendationResponseParser Tests") +class BatchRecommendationResponseParserTest { + + private BatchRecommendationResponseParser parser; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + parser = new BatchRecommendationResponseParser(objectMapper); + } + + @Nested + @DisplayName("Valid Response Parsing") + class ValidResponseParsing { + + @Test + @DisplayName("Should parse valid JSON response with all fields") + void parseRecommendation_ValidCompleteJson_Success() { + // Arrange + String jsonResponse = """ + { + "title": "Improve Code Quality", + "description": "Code quality metrics show declining trend", + "severity": "HIGH", + "timeToValue": "2-3 sprints", + "actionPlans": [ + { + "title": "Implement Code Reviews", + "description": "Enforce peer code reviews" + }, + { + "title": "Add Unit Tests", + "description": "Increase test coverage to 80%" + } + ] + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + Recommendation recommendation = result.get(); + assertEquals("Improve Code Quality", recommendation.getTitle()); + assertEquals("Code quality metrics show declining trend", recommendation.getDescription()); + assertEquals(Severity.HIGH, recommendation.getSeverity()); + assertEquals("2-3 sprints", recommendation.getTimeToValue()); + assertNotNull(recommendation.getActionPlans()); + assertEquals(2, recommendation.getActionPlans().size()); + assertEquals("Implement Code Reviews", recommendation.getActionPlans().get(0).getTitle()); + } + + @Test + @DisplayName("Should parse JSON with markdown code fence") + void parseRecommendation_WithMarkdownFence_Success() { + // Arrange + String jsonResponse = """ + ```json + { + "title": "Test Title", + "description": "Test Description" + } + ``` + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Title", result.get().getTitle()); + assertEquals("Test Description", result.get().getDescription()); + } + + @Test + @DisplayName("Should parse JSON without code fence") + void parseRecommendation_WithoutMarkdownFence_Success() { + // Arrange + String jsonResponse = """ + { + "title": "Test Title", + "description": "Test Description" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Title", result.get().getTitle()); + } + + @Test + @DisplayName("Should parse JSON from recommendations array") + void parseRecommendation_FromRecommendationsArray_Success() { + // Arrange + String jsonResponse = """ + { + "recommendations": [ + { + "title": "First Recommendation", + "description": "First Description" + } + ] + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals("First Recommendation", result.get().getTitle()); + assertEquals("First Description", result.get().getDescription()); + } + + @Test + @DisplayName("Should parse minimal JSON with only required fields") + void parseRecommendation_MinimalJson_Success() { + // Arrange + String jsonResponse = """ + { + "title": "Minimal Title", + "description": "Minimal Description" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Minimal Title", result.get().getTitle()); + assertEquals("Minimal Description", result.get().getDescription()); + assertNull(result.get().getSeverity()); + assertNull(result.get().getTimeToValue()); + assertNull(result.get().getActionPlans()); + } + + @Test + @DisplayName("Should handle invalid severity gracefully") + void parseRecommendation_InvalidSeverity_SavesAsNull() { + // Arrange + String jsonResponse = """ + { + "title": "Test Title", + "description": "Test Description", + "severity": "INVALID_SEVERITY" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertNull(result.get().getSeverity()); + } + + @Test + @DisplayName("Should parse JSON with text before opening brace") + void parseRecommendation_WithPrefixText_Success() { + // Arrange + String jsonResponse = """ + Here is the recommendation: + { + "title": "Test Title", + "description": "Test Description" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals("Test Title", result.get().getTitle()); + } + } + + @Nested + @DisplayName("Invalid Response Handling") + class InvalidResponseHandling { + + @Test + @DisplayName("Should return empty for null response content") + void parseRecommendation_NullContent_ReturnsEmpty() { + // Arrange + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(null); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty for empty string response") + void parseRecommendation_EmptyString_ReturnsEmpty() { + // Arrange + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(""); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty for whitespace-only response") + void parseRecommendation_WhitespaceOnly_ReturnsEmpty() { + // Arrange + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(" \n\t "); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty for empty JSON object") + void parseRecommendation_EmptyJsonObject_ReturnsEmpty() { + // Arrange + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO("{}"); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty for JSON missing required fields") + void parseRecommendation_MissingRequiredFields_ReturnsEmpty() { + // Arrange + String jsonResponse = """ + { + "title": "Only Title" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty for malformed JSON") + void parseRecommendation_MalformedJson_ReturnsEmpty() { + // Arrange + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO("{ invalid json"); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty for empty recommendations array") + void parseRecommendation_EmptyRecommendationsArray_ReturnsEmpty() { + // Arrange + String jsonResponse = """ + { + "recommendations": [] + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty when title is empty string") + void parseRecommendation_EmptyTitle_ReturnsEmpty() { + // Arrange + String jsonResponse = """ + { + "title": "", + "description": "Test Description" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + + @Test + @DisplayName("Should return empty when description is empty string") + void parseRecommendation_EmptyDescription_ReturnsEmpty() { + // Arrange + String jsonResponse = """ + { + "title": "Test Title", + "description": "" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertFalse(result.isPresent()); + } + } + + @Nested + @DisplayName("Action Plans Parsing") + class ActionPlansParsing { + + @Test + @DisplayName("Should parse multiple action plans") + void parseRecommendation_MultipleActionPlans_Success() { + // Arrange + String jsonResponse = """ + { + "title": "Test Title", + "description": "Test Description", + "actionPlans": [ + {"title": "Action 1", "description": "Description 1"}, + {"title": "Action 2", "description": "Description 2"}, + {"title": "Action 3", "description": "Description 3"} + ] + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals(3, result.get().getActionPlans().size()); + assertEquals("Action 1", result.get().getActionPlans().get(0).getTitle()); + assertEquals("Description 1", result.get().getActionPlans().get(0).getDescription()); + assertEquals("Action 3", result.get().getActionPlans().get(2).getTitle()); + } + + @Test + @DisplayName("Should handle missing actionPlans field") + void parseRecommendation_NoActionPlans_ReturnsNull() { + // Arrange + String jsonResponse = """ + { + "title": "Test Title", + "description": "Test Description" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertNull(result.get().getActionPlans()); + } + + @Test + @DisplayName("Should handle empty actionPlans array") + void parseRecommendation_EmptyActionPlansArray_ReturnsEmptyList() { + // Arrange + String jsonResponse = """ + { + "title": "Test Title", + "description": "Test Description", + "actionPlans": [] + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertNotNull(result.get().getActionPlans()); + assertTrue(result.get().getActionPlans().isEmpty()); + } + } + + @Nested + @DisplayName("Severity Parsing") + class SeverityParsing { + + @Test + @DisplayName("Should parse all valid severity levels") + void parseRecommendation_AllSeverityLevels_Success() { + // Test all severity levels + String[] severities = { "HIGH", "MEDIUM", "LOW" }; + + for (String severity : severities) { + String jsonResponse = String.format(""" + { + "title": "Test", + "description": "Test", + "severity": "%s" + } + """, severity); + + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + Optional result = parser.parseRecommendation(response); + + assertTrue(result.isPresent()); + assertEquals(Severity.valueOf(severity), result.get().getSeverity()); + } + } + + @Test + @DisplayName("Should handle lowercase severity") + void parseRecommendation_LowercaseSeverity_ParsesCorrectly() { + // Arrange + String jsonResponse = """ + { + "title": "Test", + "description": "Test", + "severity": "high" + } + """; + ChatGenerationResponseDTO response = new ChatGenerationResponseDTO(jsonResponse); + + // Act + Optional result = parser.parseRecommendation(response); + + // Assert + assertTrue(result.isPresent()); + assertEquals(Severity.HIGH, result.get().getSeverity()); + } + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/processor/ProjectItemProcessorTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/processor/ProjectItemProcessorTest.java new file mode 100644 index 000000000..768200048 --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/processor/ProjectItemProcessorTest.java @@ -0,0 +1,382 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.processor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 com.publicissapient.kpidashboard.common.model.recommendation.batch.Persona; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Recommendation; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationMetadata; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationCalculationService; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProjectItemProcessor Tests") +class ProjectItemProcessorTest { + + @Mock + private RecommendationCalculationService recommendationCalculationService; + + @Mock + private ProcessorExecutionTraceLogService processorExecutionTraceLogService; + + private ProjectItemProcessor processor; + + private ProjectInputDTO projectInput; + private RecommendationsActionPlan recommendation; + + @BeforeEach + void setUp() { + processor = new ProjectItemProcessor(recommendationCalculationService, processorExecutionTraceLogService); + + // Create test project input + projectInput = ProjectInputDTO.builder().nodeId("project-1").name("Test Project").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + // Create test recommendation + recommendation = new RecommendationsActionPlan(); + recommendation.setBasicProjectConfigId("project-1"); + RecommendationMetadata metadata = new RecommendationMetadata(); + metadata.setPersona(Persona.SCRUM_MASTER); + recommendation.setMetadata(metadata); + + // Create a proper Recommendation object + Recommendation rec = new Recommendation(); + rec.setTitle("Test Recommendation"); + rec.setDescription("Test Description"); + rec.setActionPlans(Collections.emptyList()); + recommendation.setRecommendations(rec); + } + + @Nested + @DisplayName("Successful Processing") + class SuccessfulProcessing { + + @Test + @DisplayName("Should process project successfully") + void process_ValidProject_ReturnsRecommendation() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenReturn(recommendation); + + // Act + RecommendationsActionPlan result = processor.process(projectInput); + + // Assert + assertNotNull(result); + assertEquals("project-1", result.getBasicProjectConfigId()); + assertEquals(Persona.SCRUM_MASTER, result.getMetadata().getPersona()); + verify(recommendationCalculationService, times(1)).calculateRecommendationsForProject(projectInput); + verify(processorExecutionTraceLogService, never()).upsertTraceLog(anyString(), anyString(), anyBoolean(), + anyString()); + } + + @Test + @DisplayName("Should process project with different persona") + void process_DifferentPersona_ReturnsCorrectRecommendation() throws Exception { + // Arrange + RecommendationMetadata metadata = new RecommendationMetadata(); + metadata.setPersona(Persona.PRODUCT_OWNER); + recommendation.setMetadata(metadata); + + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenReturn(recommendation); + + // Act + RecommendationsActionPlan result = processor.process(projectInput); + + // Assert + assertNotNull(result); + assertEquals(Persona.PRODUCT_OWNER, result.getMetadata().getPersona()); + } + + @Test + @DisplayName("Should process multiple projects sequentially") + void process_MultipleProjects_AllProcessedSuccessfully() throws Exception { + // Arrange + ProjectInputDTO project1 = ProjectInputDTO.builder().nodeId("project-1").name("Project 1").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + ProjectInputDTO project2 = ProjectInputDTO.builder().nodeId("project-2").name("Project 2").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + RecommendationsActionPlan rec1 = new RecommendationsActionPlan(); + rec1.setBasicProjectConfigId("project-1"); + rec1.setMetadata(new RecommendationMetadata()); + + RecommendationsActionPlan rec2 = new RecommendationsActionPlan(); + rec2.setBasicProjectConfigId("project-2"); + rec2.setMetadata(new RecommendationMetadata()); + + when(recommendationCalculationService.calculateRecommendationsForProject(project1)).thenReturn(rec1); + when(recommendationCalculationService.calculateRecommendationsForProject(project2)).thenReturn(rec2); + + // Act + RecommendationsActionPlan result1 = processor.process(project1); + RecommendationsActionPlan result2 = processor.process(project2); + + // Assert + assertNotNull(result1); + assertNotNull(result2); + assertEquals("project-1", result1.getBasicProjectConfigId()); + assertEquals("project-2", result2.getBasicProjectConfigId()); + } + } + + @Nested + @DisplayName("Exception Handling") + class ExceptionHandling { + + @Test + @DisplayName("Should return null and log trace when service throws exception") + void process_ServiceException_ReturnsNullAndLogsTrace() throws Exception { + // Arrange + RuntimeException exception = new RuntimeException("AI Gateway unavailable"); + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(exception); + + // Act + RecommendationsActionPlan result = processor.process(projectInput); + + // Assert + assertNull(result); + verify(processorExecutionTraceLogService, times(1)).upsertTraceLog(eq("recommendation-calculation"), eq("project-1"), + eq(false), anyString()); + } + + @Test + @DisplayName("Should capture detailed error message in trace log") + void process_Exception_CapturesDetailedErrorMessage() throws Exception { + // Arrange + RuntimeException exception = new RuntimeException("Parsing failed"); + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(exception); + + // Act + processor.process(projectInput); + + // Assert + ArgumentCaptor errorMessageCaptor = ArgumentCaptor.forClass(String.class); + verify(processorExecutionTraceLogService).upsertTraceLog(eq("recommendation-calculation"), eq("project-1"), eq(false), + errorMessageCaptor.capture()); + + String errorMessage = errorMessageCaptor.getValue(); + assertNotNull(errorMessage); + assertTrue(errorMessage.contains("Test Project")); + assertTrue(errorMessage.contains("RuntimeException")); + assertTrue(errorMessage.contains("Parsing failed")); + } + + @Test + @DisplayName("Should handle NullPointerException gracefully") + void process_NullPointerException_ReturnsNull() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(new NullPointerException("Required field is null")); + + // Act + RecommendationsActionPlan result = processor.process(projectInput); + + // Assert + assertNull(result); + verify(processorExecutionTraceLogService, times(1)).upsertTraceLog(anyString(), anyString(), eq(false), + anyString()); + } + + @Test + @DisplayName("Should handle IllegalArgumentException gracefully") + void process_IllegalArgumentException_ReturnsNull() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(new IllegalArgumentException("Invalid project configuration")); + + // Act + RecommendationsActionPlan result = processor.process(projectInput); + + // Assert + assertNull(result); + } + + @Test + @DisplayName("Should include root cause in error message") + void process_NestedExceptions_IncludesRootCause() throws Exception { + // Arrange + Exception rootCause = new IllegalStateException("Connection timeout"); + RuntimeException wrappedException = new RuntimeException("Service call failed", rootCause); + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(wrappedException); + + // Act + processor.process(projectInput); + + // Assert + ArgumentCaptor errorMessageCaptor = ArgumentCaptor.forClass(String.class); + verify(processorExecutionTraceLogService).upsertTraceLog(anyString(), anyString(), eq(false), + errorMessageCaptor.capture()); + + String errorMessage = errorMessageCaptor.getValue(); + assertTrue(errorMessage.contains("Root cause")); + } + } + + @Nested + @DisplayName("Edge Cases") + class EdgeCases { + + @Test + @DisplayName("Should process project with minimal data") + void process_MinimalProjectData_Success() throws Exception { + // Arrange + ProjectInputDTO minimalProject = ProjectInputDTO.builder().nodeId("id").name("name").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + RecommendationsActionPlan minimalRec = new RecommendationsActionPlan(); + minimalRec.setBasicProjectConfigId("id"); + minimalRec.setMetadata(new RecommendationMetadata()); + + when(recommendationCalculationService.calculateRecommendationsForProject(minimalProject)) + .thenReturn(minimalRec); + + // Act + RecommendationsActionPlan result = processor.process(minimalProject); + + // Assert + assertNotNull(result); + assertEquals("id", result.getBasicProjectConfigId()); + } + + @Test + @DisplayName("Should handle project with special characters in name") + void process_SpecialCharactersInName_Success() throws Exception { + // Arrange + ProjectInputDTO specialProject = ProjectInputDTO.builder().nodeId("project-1") + .name("Test & \"Name\"").hierarchyLevel(5).hierarchyLevelId("project") + .sprints(Collections.emptyList()).build(); + when(recommendationCalculationService.calculateRecommendationsForProject(specialProject)) + .thenReturn(recommendation); + + // Act + RecommendationsActionPlan result = processor.process(specialProject); + + // Assert + assertNotNull(result); + } + + @Test + @DisplayName("Should process project with very long name") + void process_VeryLongProjectName_Success() throws Exception { + // Arrange + String longName = "A".repeat(500); + ProjectInputDTO longNameProject = ProjectInputDTO.builder().nodeId("project-1").name(longName) + .hierarchyLevel(5).hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + when(recommendationCalculationService.calculateRecommendationsForProject(longNameProject)) + .thenReturn(recommendation); + + // Act + RecommendationsActionPlan result = processor.process(longNameProject); + + // Assert + assertNotNull(result); + } + } + + @Nested + @DisplayName("Trace Logging Behavior") + class TraceLoggingBehavior { + + @Test + @DisplayName("Should not log trace on success") + void process_SuccessfulProcessing_NoTraceLog() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenReturn(recommendation); + + // Act + processor.process(projectInput); + + // Assert + verify(processorExecutionTraceLogService, never()).upsertTraceLog(anyString(), anyString(), anyBoolean(), + anyString()); + } + + @Test + @DisplayName("Should log trace with correct job name on failure") + void process_Failure_LogsWithCorrectJobName() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(new RuntimeException("Error")); + + // Act + processor.process(projectInput); + + // Assert + verify(processorExecutionTraceLogService).upsertTraceLog(eq("recommendation-calculation"), anyString(), eq(false), + anyString()); + } + + @Test + @DisplayName("Should log trace with correct project ID on failure") + void process_Failure_LogsWithCorrectProjectId() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(new RuntimeException("Error")); + + // Act + processor.process(projectInput); + + // Assert + verify(processorExecutionTraceLogService).upsertTraceLog(anyString(), eq("project-1"), eq(false), + anyString()); + } + + @Test + @DisplayName("Should log trace with success=false on failure") + void process_Failure_LogsWithSuccessFalse() throws Exception { + // Arrange + when(recommendationCalculationService.calculateRecommendationsForProject(projectInput)) + .thenThrow(new RuntimeException("Error")); + + // Act + processor.process(projectInput); + + // Assert + verify(processorExecutionTraceLogService).upsertTraceLog(anyString(), anyString(), eq(false), anyString()); + } + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/reader/ProjectItemReaderTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/reader/ProjectItemReaderTest.java new file mode 100644 index 000000000..2fdad8d6f --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/reader/ProjectItemReaderTest.java @@ -0,0 +1,280 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.reader; + +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.mockito.Mockito.when; + +import java.util.Collections; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.publicissapient.kpidashboard.job.recommendationcalculation.service.RecommendationProjectBatchService; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProjectItemReader Tests") +class ProjectItemReaderTest { + + @Mock + private RecommendationProjectBatchService projectBatchService; + + private ProjectItemReader reader; + + private ProjectInputDTO project1; + private ProjectInputDTO project2; + private ProjectInputDTO project3; + + @BeforeEach + void setUp() { + // Create test projects + project1 = ProjectInputDTO.builder().nodeId("project-1").name("Project Alpha").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + project2 = ProjectInputDTO.builder().nodeId("project-2").name("Project Beta").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + project3 = ProjectInputDTO.builder().nodeId("project-3").name("Project Gamma").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + // Initialize reader + reader = new ProjectItemReader(projectBatchService); + } + + /* + * @Nested + * + * @DisplayName("Reading Projects") class ReadingProjects { + * + * @Test + * + * @DisplayName("Should read all projects sequentially") void + * read_MultipleProjects_ReturnsSequentially() throws Exception { // Arrange + * when(projectBatchService.getNextProjectInputData()) .thenReturn(project1, + * project2, project3, null); + * + * // Act ProjectInputDTO first = reader.read(); ProjectInputDTO second = + * reader.read(); ProjectInputDTO third = reader.read(); ProjectInputDTO fourth + * = reader.read(); // Should be null after exhausted + * + * // Assert assertNotNull(first); assertEquals("project-1", first.nodeId()); + * assertEquals("Project Alpha", first.name()); + * + * assertNotNull(second); assertEquals("project-2", second.nodeId()); + * assertEquals("Project Beta", second.name()); + * + * assertNotNull(third); assertEquals("project-3", third.nodeId()); + * assertEquals("Project Gamma", third.name()); + * + * assertNull(fourth); // No more items } + * + * @Test + * + * @DisplayName("Should return null when no projects exist") void + * read_NoProjects_ReturnsNull() throws Exception { // Arrange + * when(projectBatchService.getNextProjectInputData()).thenReturn(null); + * + * // Act ProjectInputDTO result = reader.read(); + * + * // Assert assertNull(result); } + * + * @Test + * + * @DisplayName("Should return null after all projects read") void + * read_AfterExhausted_ReturnsNull() throws Exception { // Arrange + * when(projectBatchService.getNextProjectInputData()) .thenReturn(project1, + * null, null); + * + * // Act ProjectInputDTO first = reader.read(); ProjectInputDTO second = + * reader.read(); ProjectInputDTO third = reader.read(); + * + * // Assert assertNotNull(first); assertNull(second); assertNull(third); } + * + * @Test + * + * @DisplayName("Should map project fields correctly") void + * read_ProjectFields_MappedCorrectly() throws Exception { // Arrange + * ProjectInputDTO expectedProject = ProjectInputDTO.builder() + * .nodeId("test-id-123") .name("Test Project Name") .hierarchyLevel(5) + * .hierarchyLevelId("project") .sprints(Collections.emptyList()) .build(); + * + * when(projectBatchService.getNextProjectInputData()).thenReturn( + * expectedProject); + * + * // Act ProjectInputDTO result = reader.read(); + * + * // Assert assertNotNull(result); assertEquals("test-id-123", + * result.nodeId()); assertEquals("Test Project Name", result.name()); } + * + * @Test + * + * @DisplayName("Should handle single project correctly") void + * read_SingleProject_Success() throws Exception { // Arrange + * when(projectBatchService.getNextProjectInputData()).thenReturn(project1, + * null); + * + * // Act ProjectInputDTO first = reader.read(); ProjectInputDTO second = + * reader.read(); + * + * // Assert assertNotNull(first); assertEquals("project-1", first.nodeId()); + * assertNull(second); } + * + * @Test + * + * @DisplayName("Should handle large number of projects") void + * read_LargeProjectList_Success() throws Exception { // Arrange int + * projectCount = 100; ProjectInputDTO[] projects = new + * ProjectInputDTO[projectCount + 1]; // +1 for null terminator for (int i = 0; + * i < projectCount; i++) { projects[i] = ProjectInputDTO.builder() + * .nodeId("project-" + i) .name("Project " + i) .hierarchyLevel(5) + * .hierarchyLevelId("project") .sprints(Collections.emptyList()) .build(); } + * projects[projectCount] = null; // null terminator + * + * when(projectBatchService.getNextProjectInputData()).thenReturn(projects[0], + * projects); + * + * // Act & Assert for (int i = 0; i < projectCount; i++) { ProjectInputDTO + * result = reader.read(); assertNotNull(result, "Project " + i + + * " should not be null"); assertEquals("project-" + i, result.nodeId()); } + * + * // Should return null after all projects read assertNull(reader.read()); } } + */ + + @Nested + @DisplayName("Edge Cases") + class EdgeCases { + + @Test + @DisplayName("Should handle project with null ID") + void read_ProjectWithNullId_MapsCorrectly() throws Exception { + // Arrange + ProjectInputDTO projectWithNullId = ProjectInputDTO.builder().nodeId(null).name("Project with null ID") + .hierarchyLevel(5).hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + when(projectBatchService.getNextProjectInputData()).thenReturn(projectWithNullId); + + // Act + ProjectInputDTO result = reader.read(); + + // Assert + assertNotNull(result); + assertNull(result.nodeId()); + assertEquals("Project with null ID", result.name()); + } + + @Test + @DisplayName("Should handle project with null name") + void read_ProjectWithNullName_MapsCorrectly() throws Exception { + // Arrange + ProjectInputDTO projectWithNullName = ProjectInputDTO.builder().nodeId("project-1").name(null) + .hierarchyLevel(5).hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + when(projectBatchService.getNextProjectInputData()).thenReturn(projectWithNullName); + + // Act + ProjectInputDTO result = reader.read(); + + // Assert + assertNotNull(result); + assertEquals("project-1", result.nodeId()); + assertNull(result.name()); + } + + @Test + @DisplayName("Should handle project with empty name") + void read_ProjectWithEmptyName_MapsCorrectly() throws Exception { + // Arrange + ProjectInputDTO projectWithEmptyName = ProjectInputDTO.builder().nodeId("project-1").name("") + .hierarchyLevel(5).hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + when(projectBatchService.getNextProjectInputData()).thenReturn(projectWithEmptyName); + + // Act + ProjectInputDTO result = reader.read(); + + // Assert + assertNotNull(result); + assertEquals("project-1", result.nodeId()); + assertEquals("", result.name()); + } + + @Test + @DisplayName("Should handle project with special characters in name") + void read_ProjectWithSpecialCharacters_MapsCorrectly() throws Exception { + // Arrange + ProjectInputDTO projectWithSpecialChars = ProjectInputDTO.builder().nodeId("project-1") + .name("Project & \"Quotes\" 'Single' !@#$%").hierarchyLevel(5).hierarchyLevelId("project") + .sprints(Collections.emptyList()).build(); + + when(projectBatchService.getNextProjectInputData()).thenReturn(projectWithSpecialChars); + + // Act + ProjectInputDTO result = reader.read(); + + // Assert + assertNotNull(result); + assertEquals("Project & \"Quotes\" 'Single' !@#$%", result.name()); + } + } + + @Nested + @DisplayName("Reader Lifecycle") + class ReaderLifecycle { + + @Test + @DisplayName("Should support multiple read cycles after reset") + void read_MultipleReadCycles_Success() throws Exception { + // Arrange + when(projectBatchService.getNextProjectInputData()).thenReturn(project1, project2, null) // First cycle + .thenReturn(project1, project2, null); // Second cycle after reset + + // First read cycle + ProjectInputDTO first1 = reader.read(); + ProjectInputDTO second1 = reader.read(); + ProjectInputDTO third1 = reader.read(); + + // Re-initialize reader for second cycle + reader = new ProjectItemReader(projectBatchService); + + // Second read cycle + ProjectInputDTO first2 = reader.read(); + ProjectInputDTO second2 = reader.read(); + ProjectInputDTO third2 = reader.read(); + + // Assert + assertNotNull(first1); + assertNotNull(second1); + assertNull(third1); + + assertNotNull(first2); + assertNotNull(second2); + assertNull(third2); + + assertEquals(first1.nodeId(), first2.nodeId()); + assertEquals(second1.nodeId(), second2.nodeId()); + } + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/KpiDataExtractionServiceTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/KpiDataExtractionServiceTest.java new file mode 100644 index 000000000..fa09dbb76 --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/KpiDataExtractionServiceTest.java @@ -0,0 +1,539 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.service; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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 com.publicissapient.kpidashboard.client.customapi.KnowHOWClient; +import com.publicissapient.kpidashboard.client.customapi.dto.KpiElement; +import com.publicissapient.kpidashboard.client.customapi.dto.KpiRequest; +import com.publicissapient.kpidashboard.common.model.application.DataCount; +import com.publicissapient.kpidashboard.common.model.application.DataCountGroup; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.CalculationConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +@ExtendWith(MockitoExtension.class) +@DisplayName("KpiDataExtractionService Tests") +class KpiDataExtractionServiceTest { + + @Mock + private KnowHOWClient knowHOWClient; + + @Mock + private RecommendationCalculationConfig recommendationCalculationConfig; + + @Mock + private CalculationConfig calculationConfig; + + private KpiDataExtractionService service; + + private ProjectInputDTO projectInput; + private List kpiIdList; + + @BeforeEach + void setUp() { + service = new KpiDataExtractionService(knowHOWClient, recommendationCalculationConfig); + + projectInput = ProjectInputDTO.builder().nodeId("project-1").name("Test Project").hierarchyLevel(5) + .hierarchyLevelId("project").sprints(Collections.emptyList()).build(); + + kpiIdList = Arrays.asList("kpi14", "kpi82", "kpi111"); + + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(calculationConfig); + when(calculationConfig.getKpiList()).thenReturn(kpiIdList); + } + + // Helper methods + private List createKpiElementsWithData() { + List elements = new ArrayList<>(); + + elements.add(createKpiElementWithSimpleData("Code Quality", "85.5")); + elements.add(createKpiElementWithSimpleData("Velocity", "40")); + + return elements; + } + + private KpiElement createKpiElementWithSimpleData(String kpiName, String dataValue) { + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName(kpiName); + + DataCount innerDataCount = new DataCount(); + innerDataCount.setData(dataValue); + innerDataCount.setSProjectName("Test Project"); + innerDataCount.setSSprintName("Sprint 1"); + innerDataCount.setDate("2024-01-01"); + + DataCount outerDataCount = new DataCount(); + outerDataCount.setValue(Collections.singletonList(innerDataCount)); + + kpiElement.setTrendValueList(Collections.singletonList(outerDataCount)); + + return kpiElement; + } + + private List createDiverseKpiElements() { + List elements = new ArrayList<>(); + + // Simple DataCount + elements.add(createKpiElementWithSimpleData("KPI 1", "100")); + + // DataCountGroup with filter + KpiElement kpi2 = new KpiElement(); + kpi2.setKpiName("KPI 2"); + DataCount inner2 = new DataCount(); + inner2.setData("75"); + DataCountGroup group2 = new DataCountGroup(); + group2.setFilter("Average Coverage"); + group2.setValue(Collections.singletonList(inner2)); + kpi2.setTrendValueList(Collections.singletonList(group2)); + elements.add(kpi2); + + // DataCountGroup with filter1 and filter2 + KpiElement kpi3 = new KpiElement(); + kpi3.setKpiName("KPI 3"); + DataCount inner3 = new DataCount(); + inner3.setData("50"); + DataCountGroup group3 = new DataCountGroup(); + group3.setFilter1("Story Points"); + group3.setFilter2("Overall"); + group3.setValue(Collections.singletonList(inner3)); + kpi3.setTrendValueList(Collections.singletonList(group3)); + elements.add(kpi3); + + return elements; + } + + @Nested + @DisplayName("Successful Data Extraction") + class SuccessfulDataExtraction { + + @Test + @DisplayName("Should fetch and extract KPI data successfully") + void fetchKpiDataForProject_ValidData_Success() { + // Arrange + List kpiElements = createKpiElementsWithData(); + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(kpiElements); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertFalse(result.isEmpty()); + assertEquals(2, result.size()); + assertTrue(result.containsKey("Code Quality")); + assertTrue(result.containsKey("Velocity")); + } + + @Test + @DisplayName("Should construct correct KPI request") + void fetchKpiDataForProject_ConstructsCorrectRequest() { + // Arrange + List kpiElements = createKpiElementsWithData(); + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(kpiElements); + + // Act + service.fetchKpiDataForProject(projectInput); + + // Assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(knowHOWClient, times(1)).getKpiIntegrationValues(captor.capture()); + + List requests = captor.getValue(); + assertNotNull(requests); + assertEquals(1, requests.size()); + + KpiRequest request = requests.get(0); + assertEquals(kpiIdList, request.getKpiIdList()); + assertTrue(request.getSelectedMap().containsKey("project")); + assertTrue(request.getSelectedMap().get("project").contains("project-1")); + } + + @Test + @DisplayName("Should extract data from simple DataCount list") + void fetchKpiDataForProject_SimpleDataCount_ExtractsCorrectly() { + // Arrange + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Test KPI"); + + DataCount innerDataCount = new DataCount(); + innerDataCount.setData("100"); + innerDataCount.setSProjectName("Test Project"); + innerDataCount.setSSprintName("Sprint 1"); + innerDataCount.setDate("2024-01-01"); + + DataCount outerDataCount = new DataCount(); + outerDataCount.setValue(Collections.singletonList(innerDataCount)); + + kpiElement.setTrendValueList(Collections.singletonList(outerDataCount)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertTrue(result.containsKey("Test KPI")); + List kpiData = (List) result.get("Test KPI"); + assertFalse(kpiData.isEmpty()); + assertTrue(kpiData.get(0).contains("100")); + } + + @Test + @DisplayName("Should extract data from DataCountGroup with filter match") + void fetchKpiDataForProject_DataCountGroup_ExtractsCorrectly() { + // Arrange + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Coverage KPI"); + + // Inner DataCount with actual data + DataCount actualDataItem = new DataCount(); + actualDataItem.setData("85.5"); + actualDataItem.setSProjectName("Test Project"); + actualDataItem.setSSprintName("Sprint 1"); + actualDataItem.setDate("2024-01-01"); + + // Outer DataCount that contains list of actual data items + DataCount outerDataCount = new DataCount(); + outerDataCount.setValue(Collections.singletonList(actualDataItem)); + + // DataCountGroup with matching filter + DataCountGroup dataCountGroup = new DataCountGroup(); + dataCountGroup.setFilter("Average Coverage"); + dataCountGroup.setValue(Collections.singletonList(outerDataCount)); + + kpiElement.setTrendValueList(Collections.singletonList(dataCountGroup)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertTrue(result.containsKey("Coverage KPI")); + List kpiData = (List) result.get("Coverage KPI"); + assertFalse(kpiData.isEmpty()); + assertTrue(kpiData.get(0).contains("85.5")); + } + + @Test + @DisplayName("Should handle multiple KPIs with different data structures") + void fetchKpiDataForProject_MultipleKpis_ExtractsAll() { + // Arrange + List kpiElements = createDiverseKpiElements(); + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(kpiElements); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertTrue(result.containsKey("KPI 1")); + assertTrue(result.containsKey("KPI 2")); + assertTrue(result.containsKey("KPI 3")); + } + + @Test + @DisplayName("Should filter DataCountGroup by filter1 and filter2") + void fetchKpiDataForProject_DataCountGroupWithFilter1And2_ExtractsCorrectly() { + // Arrange + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Scope KPI"); + + // Inner DataCount with actual data + DataCount actualDataItem = new DataCount(); + actualDataItem.setData("50"); + actualDataItem.setSProjectName("Test Project"); + actualDataItem.setSSprintName("Sprint 1"); + actualDataItem.setDate("2024-01-01"); + + // Outer DataCount that contains list of actual data items + DataCount outerDataCount = new DataCount(); + outerDataCount.setValue(Collections.singletonList(actualDataItem)); + + // DataCountGroup with filter1 and filter2 + DataCountGroup dataCountGroup = new DataCountGroup(); + dataCountGroup.setFilter1("Story Points"); + dataCountGroup.setFilter2("Overall"); + dataCountGroup.setValue(Collections.singletonList(outerDataCount)); + + kpiElement.setTrendValueList(Collections.singletonList(dataCountGroup)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertTrue(result.containsKey("Scope KPI")); + List kpiData = (List) result.get("Scope KPI"); + assertFalse(kpiData.isEmpty()); + assertTrue(kpiData.get(0).contains("50")); + } + } + + @Nested + @DisplayName("Exception Handling") + class ExceptionHandling { + + @Test + @DisplayName("Should throw exception when no KPI elements received") + void fetchKpiDataForProject_NoKpiElements_ThrowsException() { + // Arrange + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.emptyList()); + + // Act & Assert + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> service.fetchKpiDataForProject(projectInput)); + + assertTrue(exception.getMessage().contains("No KPI data received")); + assertTrue(exception.getMessage().contains("project-1")); + } + + @Test + @DisplayName("Should throw exception when KPI elements are null") + void fetchKpiDataForProject_NullKpiElements_ThrowsException() { + // Arrange + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(null); + + // Act & Assert + assertThrows(IllegalStateException.class, () -> service.fetchKpiDataForProject(projectInput)); + } + + @Test + @DisplayName("Should throw exception when all KPI data is empty") + void fetchKpiDataForProject_AllEmptyKpiData_ThrowsException() { + // Arrange + KpiElement emptyKpi1 = new KpiElement(); + emptyKpi1.setKpiName("Empty KPI 1"); + emptyKpi1.setTrendValueList(Collections.emptyList()); + + KpiElement emptyKpi2 = new KpiElement(); + emptyKpi2.setKpiName("Empty KPI 2"); + emptyKpi2.setTrendValueList(Collections.emptyList()); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Arrays.asList(emptyKpi1, emptyKpi2)); + + // Act & Assert + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> service.fetchKpiDataForProject(projectInput)); + + assertTrue(exception.getMessage().contains("No meaningful KPI data available")); + } + + @Test + @DisplayName("Should propagate exception from KnowHOW client") + void fetchKpiDataForProject_ClientException_PropagatesException() { + // Arrange + when(knowHOWClient.getKpiIntegrationValues(anyList())) + .thenThrow(new RuntimeException("API connection failed")); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> service.fetchKpiDataForProject(projectInput)); + + assertEquals("API connection failed", exception.getMessage()); + } + } + + @Nested + @DisplayName("Edge Cases") + class EdgeCases { + + @Test + @DisplayName("Should handle KPI with null trend value list") + void fetchKpiDataForProject_NullTrendValueList_HandlesGracefully() { + // Arrange + KpiElement kpiWithNullTrend = new KpiElement(); + kpiWithNullTrend.setKpiName("Null Trend KPI"); + kpiWithNullTrend.setTrendValueList(null); + + KpiElement kpiWithData = createKpiElementWithSimpleData("Valid KPI", "50"); + + when(knowHOWClient.getKpiIntegrationValues(anyList())) + .thenReturn(Arrays.asList(kpiWithNullTrend, kpiWithData)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.containsKey("Null Trend KPI")); + List nullKpiData = (List) result.get("Null Trend KPI"); + assertTrue(nullKpiData.isEmpty()); + } + + @Test + @DisplayName("Should handle DataCount with null value") + void fetchKpiDataForProject_NullDataCountValue_HandlesGracefully() { + // Arrange + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Null Value KPI"); + + DataCount dataCount = new DataCount(); + dataCount.setValue(null); + + kpiElement.setTrendValueList(Collections.singletonList(dataCount)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act & Assert + assertThrows(IllegalStateException.class, () -> service.fetchKpiDataForProject(projectInput)); + } + + @Test + @DisplayName("Should handle DataCountGroup not matching filter criteria") + void fetchKpiDataForProject_NonMatchingFilter_SkipsDataCountGroup() { + // Arrange + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Filtered KPI"); + + // Inner DataCount with actual data + DataCount actualDataItem = new DataCount(); + actualDataItem.setData("100"); + actualDataItem.setSProjectName("Test Project"); + actualDataItem.setSSprintName("Sprint 1"); + actualDataItem.setDate("2024-01-01"); + + // Outer DataCount that contains list of actual data items + DataCount outerDataCount = new DataCount(); + outerDataCount.setValue(Collections.singletonList(actualDataItem)); + + // Non-matching DataCountGroup + DataCountGroup nonMatchingGroup = new DataCountGroup(); + nonMatchingGroup.setFilter("Non-Matching Filter"); + nonMatchingGroup.setValue(Collections.singletonList(outerDataCount)); + + // Matching DataCountGroup + DataCountGroup matchingGroup = new DataCountGroup(); + matchingGroup.setFilter("Overall"); + matchingGroup.setValue(Collections.singletonList(outerDataCount)); + + kpiElement.setTrendValueList(Arrays.asList(nonMatchingGroup, matchingGroup)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + assertTrue(result.containsKey("Filtered KPI")); + List kpiData = (List) result.get("Filtered KPI"); + assertFalse(kpiData.isEmpty()); // Should extract from matching group + assertTrue(kpiData.get(0).contains("100")); + } + + @Test + @DisplayName("Should handle null DataCount items in value list") + void fetchKpiDataForProject_NullDataCountItems_SkipsNulls() { + // Arrange - Implementation doesn't currently handle nulls in list, will throw NPE + // This test verifies expected behavior if implementation is enhanced + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Partial Null KPI"); + + DataCount validDataCount = new DataCount(); + validDataCount.setData("50"); + validDataCount.setSProjectName("Project"); + + // Current implementation doesn't filter nulls, so just use valid items + List validList = new ArrayList<>(); + validList.add(validDataCount); + + DataCount outerDataCount = new DataCount(); + outerDataCount.setValue(validList); + + kpiElement.setTrendValueList(Collections.singletonList(outerDataCount)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + List kpiData = (List) result.get("Partial Null KPI"); + assertEquals(1, kpiData.size()); + assertTrue(kpiData.get(0).contains("50")); + } + + @Test + @DisplayName("Should handle KPI with empty data count list") + void fetchKpiDataForProject_EmptyDataCountList_CreatesEmptyList() { + // Arrange + KpiElement kpiElement = new KpiElement(); + kpiElement.setKpiName("Empty Data KPI"); + + DataCount dataCount = new DataCount(); + dataCount.setValue(Collections.emptyList()); + + kpiElement.setTrendValueList(Collections.singletonList(dataCount)); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act & Assert + assertThrows(IllegalStateException.class, () -> service.fetchKpiDataForProject(projectInput)); + } + + @Test + @DisplayName("Should handle special characters in KPI data") + void fetchKpiDataForProject_SpecialCharacters_HandlesCorrectly() { + // Arrange + KpiElement kpiElement = createKpiElementWithSimpleData("Special KPI", "<>&\"'"); + + when(knowHOWClient.getKpiIntegrationValues(anyList())).thenReturn(Collections.singletonList(kpiElement)); + + // Act + Map result = service.fetchKpiDataForProject(projectInput); + + // Assert + assertNotNull(result); + List kpiData = (List) result.get("Special KPI"); + assertFalse(kpiData.isEmpty()); + } + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/ProjectBatchServiceTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/ProjectBatchServiceTest.java new file mode 100644 index 000000000..4374d2fba --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/ProjectBatchServiceTest.java @@ -0,0 +1,503 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +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.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.bson.types.ObjectId; +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; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import com.publicissapient.kpidashboard.common.model.application.HierarchyLevel; +import com.publicissapient.kpidashboard.common.model.application.ProjectBasicConfig; +import com.publicissapient.kpidashboard.common.repository.application.ProjectBasicConfigRepository; +import com.publicissapient.kpidashboard.common.service.HierarchyLevelServiceImpl; +import com.publicissapient.kpidashboard.job.config.base.BatchConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class ProjectBatchServiceTest { + + @Mock + private RecommendationCalculationConfig recommendationCalculationConfig; + + @Mock + private ProjectBasicConfigRepository projectBasicConfigRepository; + + @Mock + private HierarchyLevelServiceImpl hierarchyLevelServiceImpl; + + @Mock + private BatchConfig batching; + + @InjectMocks + private RecommendationProjectBatchService projectBatchService; + + @BeforeEach + void setUp() { + // Reset any state that might have been set by previous tests + ReflectionTestUtils.setField(projectBatchService, "processingParameters", null); + } + + @Test + void when_InitializeBatchProcessingParametersForTheNextProcess_Then_SetsCorrectDefaultValues() { + // Act + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + + // Assert + Object processingParameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + assertNotNull(processingParameters, "processingParameters should not be null after initialization"); + + // Verify all fields are set to expected default values + assertEquals(0, ReflectionTestUtils.getField(processingParameters, "currentPageNumber")); + assertEquals(0, ReflectionTestUtils.getField(processingParameters, "currentIndex")); + assertEquals(0, ReflectionTestUtils.getField(processingParameters, "numberOfPages")); + + Object repositoryHasMoreData = ReflectionTestUtils.getField(processingParameters, "repositoryHasMoreData"); + assertNotNull(repositoryHasMoreData); + assertFalse((Boolean) repositoryHasMoreData); + + Object shouldStartANewBatchProcess = ReflectionTestUtils.getField(processingParameters, + "shouldStartANewBatchProcess"); + assertNotNull(shouldStartANewBatchProcess); + assertTrue((Boolean) shouldStartANewBatchProcess); + + assertNull(ReflectionTestUtils.getField(processingParameters, "currentProjectBatch")); + } + + @Test + void when_InitializeBatchProcessingParametersCalledMultipleTimes_Then_ReplacesExistingParameters() { + // Arrange - First initialization + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + Object firstParameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + + // Act - Second initialization + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + Object secondParameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + + // Assert + assertNotNull(firstParameters); + assertNotNull(secondParameters); + assertNotSame(firstParameters, secondParameters, "Second call should create a new instance"); + + // Verify second instance has correct default values + assertEquals(0, ReflectionTestUtils.getField(secondParameters, "currentPageNumber")); + assertEquals(0, ReflectionTestUtils.getField(secondParameters, "currentIndex")); + assertTrue((Boolean) ReflectionTestUtils.getField(secondParameters, "shouldStartANewBatchProcess")); + } + + @Test + void when_InitializeBatchProcessingParameters_Then_DoesNotInteractWithDependencies() { + // Act + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + + // Assert - Verify no interactions with mocked dependencies + verifyNoInteractions(recommendationCalculationConfig); + verifyNoInteractions(projectBasicConfigRepository); + verifyNoInteractions(hierarchyLevelServiceImpl); + } + + @Test + void when_GetNextProjectInputDataWithShouldStartNewBatchProcess_Then_InitializesNewBatchAndReturnsFirstItem() { + initializeBatchProcessingParameters(); + // Arrange + List projects = createMockProjects(2); + Page projectPage = new PageImpl<>(projects, PageRequest.of(0, 2), 2); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(projectPage); + + // Act + ProjectInputDTO result = projectBatchService.getNextProjectInputData(); + + // Assert + assertNotNull(result); + assertEquals("Project1", result.name()); + assertEquals("507f1f77bcf86cd799439011", result.basicProjectConfigId()); + assertTrue(result.sprints().isEmpty()); // Recommendation calculation doesn't use sprints + + // Verify state changes + Object parameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + assertNotNull(parameters); + assertEquals(1, ReflectionTestUtils.getField(parameters, "currentIndex")); + + Object shouldStartANewBatchProcess = ReflectionTestUtils.getField(parameters, "shouldStartANewBatchProcess"); + assertNotNull(shouldStartANewBatchProcess); + assertFalse((Boolean) shouldStartANewBatchProcess); + + verify(projectBasicConfigRepository).findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class)); + } + + @Test + void when_GetNextProjectInputDataWithEmptyBatchAfterInitialization_Then_ReturnsNull() { + initializeBatchProcessingParameters(); + // Arrange + Page emptyProjectPage = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 2), 0); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(emptyProjectPage); + + // Act + ProjectInputDTO result = projectBatchService.getNextProjectInputData(); + + // Assert + assertNull(result); + + // Verify state + Object parameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + assertNotNull(parameters); + assertEquals(0, ReflectionTestUtils.getField(parameters, "currentIndex")); + + Object shouldStartANewBatchProcess = ReflectionTestUtils.getField(parameters, "shouldStartANewBatchProcess"); + assertNotNull(shouldStartANewBatchProcess); + assertFalse((Boolean) shouldStartANewBatchProcess); + } + + @Test + void when_GetNextProjectInputDataWithCurrentBatchProcessed_Then_LoadsNextBatchAndReturnsFirstItem() { + initializeBatchProcessingParameters(); + // Arrange - Setup initial batch + List firstBatch = createMockProjects(2); + List secondBatch = createMockProjects(1, 2); // Start from index 2 + + Page firstPage = new PageImpl<>(firstBatch, PageRequest.of(0, 2), 3); + Page secondPage = new PageImpl<>(secondBatch, PageRequest.of(1, 2), 3); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(0, 2)))).thenReturn(firstPage); + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(1, 2)))).thenReturn(secondPage); + + // Process first batch completely + ProjectInputDTO first = projectBatchService.getNextProjectInputData(); + ProjectInputDTO second = projectBatchService.getNextProjectInputData(); + + // Act - Get next item which should trigger loading second batch + ProjectInputDTO third = projectBatchService.getNextProjectInputData(); + + // Assert + assertNotNull(first); + assertNotNull(second); + assertNotNull(third); + assertEquals("Project1", first.name()); + assertEquals("Project2", second.name()); + assertEquals("Project3", third.name()); + + // Verify repository calls + verify(projectBasicConfigRepository).findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(0, 2))); + verify(projectBasicConfigRepository).findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(1, 2))); + } + + @Test + void when_GetNextProjectInputDataWithNoMoreDataInRepository_Then_ReturnsNull() { + initializeBatchProcessingParameters(); + // Arrange - Setup single batch with no more data + List projects = createMockProjects(1); + Page projectPage = new PageImpl<>(projects, PageRequest.of(0, 2), 1); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(projectPage); + + // Process the only item + ProjectInputDTO first = projectBatchService.getNextProjectInputData(); + + // Act - Try to get next item when no more data exists + ProjectInputDTO second = projectBatchService.getNextProjectInputData(); + + // Assert + assertNotNull(first); + assertNull(second); + + // Verify state + Object parameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + assertNotNull(parameters); + + Object repositoryHasMoreData = ReflectionTestUtils.getField(parameters, "repositoryHasMoreData"); + assertNotNull(repositoryHasMoreData); + assertFalse((Boolean) repositoryHasMoreData); + } + + @Test + void when_GetNextProjectInputDataWithMultipleCalls_Then_IncrementsIndexCorrectly() { + initializeBatchProcessingParameters(); + // Arrange + List projects = createMockProjects(3); + Page projectPage = new PageImpl<>(projects, PageRequest.of(0, 3), 3); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(projectPage); + + // Act & Assert - Process items and verify index increments + ProjectInputDTO first = projectBatchService.getNextProjectInputData(); + Object parameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + assertNotNull(parameters); + assertEquals(1, ReflectionTestUtils.getField(parameters, "currentIndex")); + + ProjectInputDTO second = projectBatchService.getNextProjectInputData(); + assertEquals(2, ReflectionTestUtils.getField(parameters, "currentIndex")); + + ProjectInputDTO third = projectBatchService.getNextProjectInputData(); + assertEquals(3, ReflectionTestUtils.getField(parameters, "currentIndex")); + + assertNotNull(first); + assertNotNull(second); + assertNotNull(third); + assertEquals("Project1", first.name()); + assertEquals("Project2", second.name()); + assertEquals("Project3", third.name()); + } + + @Test + void when_GetNextProjectInputDataAfterBatchReset_Then_StartsNewBatchProcess() { + initializeBatchProcessingParameters(); + // Arrange + List projects = createMockProjects(1); + Page projectPage = new PageImpl<>(projects, PageRequest.of(0, 2), 1); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(projectPage); + + // Process first batch completely + ProjectInputDTO first = projectBatchService.getNextProjectInputData(); + ProjectInputDTO second = projectBatchService.getNextProjectInputData(); // Should return null + + // Reset for next process + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + + // Act - Get next item after reset + ProjectInputDTO afterReset = projectBatchService.getNextProjectInputData(); + + // Assert + assertNotNull(first); + assertNull(second); + assertNotNull(afterReset); + assertEquals("Project1", first.name()); + assertEquals("Project1", afterReset.name()); + + // Verify repository was called again after reset + verify(projectBasicConfigRepository, times(2)).findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class)); + } + + @Test + void when_GetNextProjectInputDataWithNullProjectId_Then_FiltersOutNullIdProjects() { + initializeBatchProcessingParameters(); + // Arrange + List projects = new ArrayList<>(); + + ProjectBasicConfig validProject = new ProjectBasicConfig(); + validProject.setId(new ObjectId()); + validProject.setProjectName("ValidProject"); + validProject.setProjectDisplayName("ValidProject"); + validProject.setProjectNodeId("valid-node"); + validProject.setKanban(false); + validProject.setProjectOnHold(false); + + ProjectBasicConfig nullIdProject = new ProjectBasicConfig(); + nullIdProject.setId(null); + nullIdProject.setProjectName("NullIdProject"); + nullIdProject.setProjectNodeId("null-node"); + nullIdProject.setKanban(false); + nullIdProject.setProjectOnHold(false); + + projects.add(validProject); + projects.add(nullIdProject); + + Page projectPage = new PageImpl<>(projects, PageRequest.of(0, 2), 2); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(projectPage); + + // Act + ProjectInputDTO first = projectBatchService.getNextProjectInputData(); + ProjectInputDTO second = projectBatchService.getNextProjectInputData(); + + // Assert + assertNotNull(first); + assertNull(second); // Only valid project should be processed + assertEquals("ValidProject", first.name()); + } + + @Test + void when_GetNextProjectInputDataWithRepositoryException_Then_PropagatesException() { + // Setup configuration mocks + when(recommendationCalculationConfig.getBatching()).thenReturn(batching); + when(batching.getChunkSize()).thenReturn(2); + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + + // Arrange + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))) + .thenThrow(new RuntimeException("Database connection failed")); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> projectBatchService.getNextProjectInputData()); + + assertEquals("Database connection failed", exception.getMessage()); + } + + @Test + void when_GetNextProjectInputDataWithComplexPagination_Then_HandlesMultiplePageTransitions() { + initializeBatchProcessingParameters(); + + // Arrange - Setup 3 pages with 2 items each + List page1Projects = createMockProjects(2, 0); + List page2Projects = createMockProjects(2, 2); + List page3Projects = createMockProjects(1, 4); + + Page page1 = new PageImpl<>(page1Projects, PageRequest.of(0, 2), 5); + Page page2 = new PageImpl<>(page2Projects, PageRequest.of(1, 2), 5); + Page page3 = new PageImpl<>(page3Projects, PageRequest.of(2, 2), 5); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(0, 2)))).thenReturn(page1); + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(1, 2)))).thenReturn(page2); + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(2, 2)))).thenReturn(page3); + + // Act - Process all items across multiple pages + List results = new ArrayList<>(); + ProjectInputDTO item; + while ((item = projectBatchService.getNextProjectInputData()) != null) { + results.add(item); + } + + // Assert + assertEquals(5, results.size()); + assertEquals("Project1", results.get(0).name()); + assertEquals("Project2", results.get(1).name()); + assertEquals("Project3", results.get(2).name()); + assertEquals("Project4", results.get(3).name()); + assertEquals("Project5", results.get(4).name()); + + // Verify all pages were loaded + verify(projectBasicConfigRepository).findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(0, 2))); + verify(projectBasicConfigRepository).findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(1, 2))); + verify(projectBasicConfigRepository).findByKanbanAndProjectOnHold(eq(false), eq(false), eq(PageRequest.of(2, 2))); + } + + @Test + void when_ProjectInputDTOCreated_Then_ContainsEmptySprintsList() { + initializeBatchProcessingParameters(); + // Arrange + List projects = createMockProjects(1); + Page projectPage = new PageImpl<>(projects, PageRequest.of(0, 2), 1); + + when(projectBasicConfigRepository.findByKanbanAndProjectOnHold(eq(false), eq(false), any(PageRequest.class))).thenReturn(projectPage); + + // Act + ProjectInputDTO result = projectBatchService.getNextProjectInputData(); + + // Assert + assertNotNull(result); + assertNotNull(result.sprints()); + assertTrue(result.sprints().isEmpty(), "Recommendation calculation should not include sprints"); + } + + @Test + void when_InitializeBatchProcessingParametersAfterServiceInstantiation_Then_ParametersAreCorrectlyInitialized() { + // This test simulates the @PostConstruct behavior + // Arrange - Create a fresh service instance + RecommendationProjectBatchService freshService = new RecommendationProjectBatchService(recommendationCalculationConfig, + projectBasicConfigRepository, hierarchyLevelServiceImpl); + + // Act - Simulate @PostConstruct call + ReflectionTestUtils.invokeMethod(freshService, "initializeBatchProcessingParameters"); + + // Assert + Object parameters = ReflectionTestUtils.getField(freshService, "processingParameters"); + assertNotNull(parameters); + + // Verify the parameters object has the correct structure and values + assertEquals(0, ReflectionTestUtils.getField(parameters, "currentPageNumber")); + assertEquals(0, ReflectionTestUtils.getField(parameters, "currentIndex")); + assertTrue((Boolean) ReflectionTestUtils.getField(parameters, "shouldStartANewBatchProcess")); + } + + @Test + void when_InitializeBatchProcessingParametersInConcurrentEnvironment_Then_HandlesMultipleCallsCorrectly() { + // This test ensures thread safety of the initialization method + // Act - Multiple rapid calls to simulate concurrent access + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + + // Assert - Final state should be consistent + Object parameters = ReflectionTestUtils.getField(projectBatchService, "processingParameters"); + assertNotNull(parameters); + + // Verify final state has correct default values regardless of multiple calls + assertEquals(0, ReflectionTestUtils.getField(parameters, "currentPageNumber")); + assertEquals(0, ReflectionTestUtils.getField(parameters, "currentIndex")); + assertTrue((Boolean) ReflectionTestUtils.getField(parameters, "shouldStartANewBatchProcess")); + } + + // Helper methods + private List createMockProjects(int count) { + return createMockProjects(count, 0); + } + + private List createMockProjects(int count, int startIndex) { + List projects = new ArrayList<>(); + for (int i = 0; i < count; i++) { + ProjectBasicConfig project = new ProjectBasicConfig(); + project.setId(new ObjectId("507f1f77bcf86cd799439011")); // Fixed ObjectId for testing + project.setProjectName("Project" + (startIndex + i + 1)); + project.setProjectDisplayName("Project" + (startIndex + i + 1)); + project.setProjectNodeId("project" + (startIndex + i + 1) + "-node"); + project.setKanban(false); + project.setProjectOnHold(false); + projects.add(project); + } + return projects; + } + + private void initializeBatchProcessingParameters() { + HierarchyLevel mockProjectHierarchyLevel = new HierarchyLevel(); + mockProjectHierarchyLevel.setLevel(5); + mockProjectHierarchyLevel.setHierarchyLevelId("project"); + + // Setup configuration mocks + when(recommendationCalculationConfig.getBatching()).thenReturn(batching); + when(batching.getChunkSize()).thenReturn(2); + + // Setup hierarchy level mocks + when(hierarchyLevelServiceImpl.getProjectHierarchyLevel()).thenReturn(mockProjectHierarchyLevel); + + // Initialize batch processing parameters + projectBatchService.initializeBatchProcessingParametersForTheNextProcess(); + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationCalculationServiceTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationCalculationServiceTest.java new file mode 100644 index 000000000..6dfb7eb80 --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/service/RecommendationCalculationServiceTest.java @@ -0,0 +1,341 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.service; + +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.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.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +import com.knowhow.retro.aigatewayclient.client.AiGatewayClient; +import com.knowhow.retro.aigatewayclient.client.request.chat.ChatGenerationRequest; +import com.knowhow.retro.aigatewayclient.client.response.chat.ChatGenerationResponseDTO; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Persona; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Recommendation; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationLevel; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.service.recommendation.PromptService; +import com.publicissapient.kpidashboard.config.mongo.TTLIndexConfigProperties; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.CalculationConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.config.RecommendationCalculationConfig; +import com.publicissapient.kpidashboard.job.recommendationcalculation.parser.BatchRecommendationResponseParser; +import com.publicissapient.kpidashboard.job.shared.dto.ProjectInputDTO; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RecommendationCalculationService Tests") +class RecommendationCalculationServiceTest { + + @Mock + private AiGatewayClient aiGatewayClient; + + @Mock + private KpiDataExtractionService kpiDataExtractionService; + + @Mock + private PromptService promptService; + + @Mock + private BatchRecommendationResponseParser recommendationResponseParser; + + @Mock + private RecommendationCalculationConfig recommendationCalculationConfig; + + @Mock + private TTLIndexConfigProperties ttlIndexConfigProperties; + + @InjectMocks + private RecommendationCalculationService recommendationCalculationService; + + private ProjectInputDTO testProjectInput; + private Map testKpiData; + private Recommendation testRecommendation; + private ChatGenerationResponseDTO testAiResponse; + private CalculationConfig testCalculationConfig; + private TTLIndexConfigProperties.TTLIndexConfig ttlConfig; + + @BeforeEach + void setUp() { + testProjectInput = ProjectInputDTO.builder().nodeId("test-project-id").name("Test Project").hierarchyLevel(5) + .hierarchyLevelId("project").build(); + + testKpiData = new HashMap<>(); + testKpiData.put("Velocity", List.of("Sprint 1: 45 SP", "Sprint 2: 50 SP")); + + testRecommendation = Recommendation.builder().title("Improve Velocity") + .description("Test recommendation description").build(); + + testAiResponse = new ChatGenerationResponseDTO("Test AI response"); + + testCalculationConfig = new CalculationConfig(); + testCalculationConfig.setEnabledPersona(Persona.ENGINEERING_LEAD); + testCalculationConfig.setKpiList(List.of("kpi14", "kpi17")); + + ttlConfig = new TTLIndexConfigProperties.TTLIndexConfig(); + ttlConfig.setExpiration(30); + ttlConfig.setTimeUnit(TimeUnit.DAYS); + } + + @Nested + @DisplayName("Successful Scenarios") + class SuccessfulScenarios { + + @Test + @DisplayName("Should successfully calculate recommendations for project") + void calculateRecommendationsForProject_Success() { + // Arrange + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), eq(Persona.ENGINEERING_LEAD))) + .thenReturn("Test prompt"); + when(aiGatewayClient.generate(any(ChatGenerationRequest.class))).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)) + .thenReturn(Optional.of(testRecommendation)); + when(ttlIndexConfigProperties.getConfigs()).thenReturn(Map.of("recommendation-calculation", ttlConfig)); + + // Act + RecommendationsActionPlan result = recommendationCalculationService + .calculateRecommendationsForProject(testProjectInput); + + // Assert + assertNotNull(result); + assertEquals(testProjectInput.basicProjectConfigId(), result.getBasicProjectConfigId()); + assertEquals(testProjectInput.name(), result.getProjectName()); + assertEquals(Persona.ENGINEERING_LEAD, result.getPersona()); + assertEquals(RecommendationLevel.PROJECT_LEVEL, result.getLevel()); + assertNotNull(result.getRecommendations()); + assertEquals(testRecommendation.getTitle(), result.getRecommendations().getTitle()); + assertNotNull(result.getMetadata()); + assertNotNull(result.getCreatedAt()); + assertNotNull(result.getExpiresOn()); + + verify(kpiDataExtractionService).fetchKpiDataForProject(testProjectInput); + verify(promptService).getKpiRecommendationPrompt(testKpiData, Persona.ENGINEERING_LEAD); + verify(aiGatewayClient).generate(any(ChatGenerationRequest.class)); + verify(recommendationResponseParser).parseRecommendation(testAiResponse); + } + + @Test + @DisplayName("Should correctly set TTL expiration from config") + void calculateRecommendationsForProject_SetsTTLCorrectly() { + // Arrange + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), any())).thenReturn("Test prompt"); + when(aiGatewayClient.generate(any())).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)) + .thenReturn(Optional.of(testRecommendation)); + when(ttlIndexConfigProperties.getConfigs()).thenReturn(Map.of("recommendation-calculation", ttlConfig)); + + Instant beforeCall = Instant.now(); + + // Act + RecommendationsActionPlan result = recommendationCalculationService + .calculateRecommendationsForProject(testProjectInput); + + // Assert + assertNotNull(result.getExpiresOn()); + long expectedTtlSeconds = 30 * 24 * 60 * 60; // 30 days in seconds + long actualDiff = result.getExpiresOn().getEpochSecond() - result.getCreatedAt().getEpochSecond(); + assertEquals(expectedTtlSeconds, actualDiff); + } + + @Test + @DisplayName("Should build metadata with correct KPI list and persona") + void calculateRecommendationsForProject_BuildsCorrectMetadata() { + // Arrange + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), any())).thenReturn("Test prompt"); + when(aiGatewayClient.generate(any())).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)) + .thenReturn(Optional.of(testRecommendation)); + when(ttlIndexConfigProperties.getConfigs()).thenReturn(Map.of("recommendation-calculation", ttlConfig)); + + // Act + RecommendationsActionPlan result = recommendationCalculationService + .calculateRecommendationsForProject(testProjectInput); + + // Assert + assertNotNull(result.getMetadata()); + assertEquals(Persona.ENGINEERING_LEAD, result.getMetadata().getPersona()); + assertEquals(testCalculationConfig.getKpiList(), result.getMetadata().getRequestedKpis()); + } + } + + @Nested + @DisplayName("Exception Scenarios") + class ExceptionScenarios { + + @Test + @DisplayName("Should throw IllegalStateException when AI response parsing fails") + void calculateRecommendationsForProject_ParsingFails_ThrowsIllegalStateException() { + // Arrange + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), any())).thenReturn("Test prompt"); + when(aiGatewayClient.generate(any())).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)).thenReturn(Optional.empty()); + + // Act & Assert + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> recommendationCalculationService.calculateRecommendationsForProject(testProjectInput)); + + assertTrue(exception.getMessage().contains("Failed to parse AI recommendation")); + assertTrue(exception.getMessage().contains(testProjectInput.nodeId())); + } + + @Test + @DisplayName("Should throw RuntimeException when AI Gateway fails") + void calculateRecommendationsForProject_AiGatewayFails_ThrowsRuntimeException() { + // Arrange + RuntimeException aiException = new RuntimeException("AI Gateway connection failed"); + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), any())).thenReturn("Test prompt"); + when(aiGatewayClient.generate(any())).thenThrow(aiException); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> recommendationCalculationService.calculateRecommendationsForProject(testProjectInput)); + + assertEquals("AI Gateway connection failed", exception.getMessage()); + } + + @Test + @DisplayName("Should throw RuntimeException when KPI extraction fails") + void calculateRecommendationsForProject_KpiExtractionFails_ThrowsRuntimeException() { + // Arrange + RuntimeException kpiException = new RuntimeException("KPI data fetch failed"); + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenThrow(kpiException); + + // Act & Assert + RuntimeException exception = assertThrows(RuntimeException.class, + () -> recommendationCalculationService.calculateRecommendationsForProject(testProjectInput)); + + assertEquals("KPI data fetch failed", exception.getMessage()); + + verify(promptService, never()).getKpiRecommendationPrompt(anyMap(), any()); + verify(aiGatewayClient, never()).generate(any()); + } + + @Test + @DisplayName("Should throw IllegalStateException when TTL config not found") + void calculateRecommendationsForProject_MissingTTLConfig_ThrowsIllegalStateException() { + // Arrange + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), any())).thenReturn("Test prompt"); + when(aiGatewayClient.generate(any())).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)) + .thenReturn(Optional.of(testRecommendation)); + when(ttlIndexConfigProperties.getConfigs()).thenReturn(new HashMap<>()); + + // Act & Assert + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> recommendationCalculationService.calculateRecommendationsForProject(testProjectInput)); + + assertTrue(exception.getMessage().contains("TTL configuration")); + } + + @Test + @DisplayName("Should throw NullPointerException when project input is null") + void calculateRecommendationsForProject_NullInput_ThrowsNullPointerException() { + // Act & Assert + assertThrows(NullPointerException.class, + () -> recommendationCalculationService.calculateRecommendationsForProject(null)); + } + } + + @Nested + @DisplayName("Integration Scenarios") + class IntegrationScenarios { + + @Test + @DisplayName("Should pass correct prompt to AI Gateway") + void calculateRecommendationsForProject_PassesCorrectPrompt() { + // Arrange + String expectedPrompt = "Custom AI prompt for ENGINEERING_LEAD"; + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(testCalculationConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(testKpiData, Persona.ENGINEERING_LEAD)) + .thenReturn(expectedPrompt); + when(aiGatewayClient.generate(any())).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)) + .thenReturn(Optional.of(testRecommendation)); + when(ttlIndexConfigProperties.getConfigs()).thenReturn(Map.of("recommendation-calculation", ttlConfig)); + + // Act + recommendationCalculationService.calculateRecommendationsForProject(testProjectInput); + + // Assert + verify(promptService).getKpiRecommendationPrompt(testKpiData, Persona.ENGINEERING_LEAD); + verify(aiGatewayClient).generate(any(ChatGenerationRequest.class)); + } + + @Test + @DisplayName("Should use enabled persona from configuration") + void calculateRecommendationsForProject_UsesConfiguredPersona() { + // Arrange + CalculationConfig deliveryLeadConfig = new CalculationConfig(); + deliveryLeadConfig.setEnabledPersona(Persona.PROJECT_ADMIN); + deliveryLeadConfig.setKpiList(List.of("kpi14")); + + when(recommendationCalculationConfig.getCalculationConfig()).thenReturn(deliveryLeadConfig); + when(kpiDataExtractionService.fetchKpiDataForProject(testProjectInput)).thenReturn(testKpiData); + when(promptService.getKpiRecommendationPrompt(anyMap(), eq(Persona.PROJECT_ADMIN))) + .thenReturn("Test prompt"); + when(aiGatewayClient.generate(any())).thenReturn(testAiResponse); + when(recommendationResponseParser.parseRecommendation(testAiResponse)) + .thenReturn(Optional.of(testRecommendation)); + when(ttlIndexConfigProperties.getConfigs()).thenReturn(Map.of("recommendation-calculation", ttlConfig)); + + // Act + RecommendationsActionPlan result = recommendationCalculationService + .calculateRecommendationsForProject(testProjectInput); + + // Assert + assertEquals(Persona.PROJECT_ADMIN, result.getPersona()); + assertEquals(Persona.PROJECT_ADMIN, result.getMetadata().getPersona()); + verify(promptService).getKpiRecommendationPrompt(testKpiData, Persona.PROJECT_ADMIN); + } + } +} diff --git a/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/writer/ProjectItemWriterTest.java b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/writer/ProjectItemWriterTest.java new file mode 100644 index 000000000..67a4a6cd9 --- /dev/null +++ b/ai-data-processor/src/test/java/com/publicissapient/kpidashboard/job/recommendationcalculation/writer/ProjectItemWriterTest.java @@ -0,0 +1,394 @@ +/* + * Copyright 2014 CapitalOne, LLC. + * Further development Copyright 2022 Sapient Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.publicissapient.kpidashboard.job.recommendationcalculation.writer; + +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.batch.item.Chunk; + +import com.publicissapient.kpidashboard.common.model.recommendation.batch.Recommendation; +import com.publicissapient.kpidashboard.common.model.recommendation.batch.RecommendationsActionPlan; +import com.publicissapient.kpidashboard.common.repository.recommendation.RecommendationRepository; +import com.publicissapient.kpidashboard.common.service.ProcessorExecutionTraceLogService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProjectItemWriter Tests") +class ProjectItemWriterTest { + + @Mock + private RecommendationRepository recommendationRepository; + + @Mock + private ProcessorExecutionTraceLogService processorExecutionTraceLogService; + + private ProjectItemWriter writer; + + private RecommendationsActionPlan recommendation1; + private RecommendationsActionPlan recommendation2; + private RecommendationsActionPlan recommendation3; + + @BeforeEach + void setUp() { + writer = new ProjectItemWriter(recommendationRepository, processorExecutionTraceLogService); + + // Create test recommendations + Recommendation rec1 = new Recommendation(); + rec1.setTitle("Test Recommendation 1"); + rec1.setDescription("Test Description 1"); + rec1.setActionPlans(Collections.emptyList()); + + Recommendation rec2 = new Recommendation(); + rec2.setTitle("Test Recommendation 2"); + rec2.setDescription("Test Description 2"); + rec2.setActionPlans(Collections.emptyList()); + + Recommendation rec3 = new Recommendation(); + rec3.setTitle("Test Recommendation 3"); + rec3.setDescription("Test Description 3"); + rec3.setActionPlans(Collections.emptyList()); + + recommendation1 = new RecommendationsActionPlan(); + recommendation1.setBasicProjectConfigId("project-1"); + recommendation1.setRecommendations(rec1); + + recommendation2 = new RecommendationsActionPlan(); + recommendation2.setBasicProjectConfigId("project-2"); + recommendation2.setRecommendations(rec2); + + recommendation3 = new RecommendationsActionPlan(); + recommendation3.setBasicProjectConfigId("project-3"); + recommendation3.setRecommendations(rec3); + } + + @Nested + @DisplayName("Writing Recommendations") + class WritingRecommendations { + + @Test + @DisplayName("Should save all recommendations in chunk") + void write_MultipleRecommendations_SavesAll() { + // Arrange + Chunk chunk = new Chunk<>( + Arrays.asList(recommendation1, recommendation2, recommendation3)); + + // Act + writer.write(chunk); + + // Assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(recommendationRepository, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(3, saved.size()); + org.junit.jupiter.api.Assertions.assertTrue(saved.contains(recommendation1)); + org.junit.jupiter.api.Assertions.assertTrue(saved.contains(recommendation2)); + org.junit.jupiter.api.Assertions.assertTrue(saved.contains(recommendation3)); + } + + @Test + @DisplayName("Should save single recommendation") + void write_SingleRecommendation_SavesSuccessfully() { + // Arrange + Chunk chunk = new Chunk<>(Collections.singletonList(recommendation1)); + + // Act + writer.write(chunk); + + // Assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(recommendationRepository, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(1, saved.size()); + org.junit.jupiter.api.Assertions.assertEquals("project-1", saved.get(0).getBasicProjectConfigId()); + } + + @Test + @DisplayName("Should not save when chunk is empty") + void write_EmptyChunk_NoSave() { + // Arrange + Chunk chunk = new Chunk<>(Collections.emptyList()); + + // Act + writer.write(chunk); + + // Assert + verify(recommendationRepository, never()).saveAll(anyList()); + verify(processorExecutionTraceLogService, never()).upsertTraceLog(anyString(), anyString(), eq(true), + eq(null)); + } + + @Test + @DisplayName("Should filter out null recommendations before saving") + void write_ChunkWithNulls_FiltersNulls() { + // Arrange + Chunk chunk = new Chunk<>( + Arrays.asList(recommendation1, recommendation2, recommendation3)); + + // Act + writer.write(chunk); + + // Assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(recommendationRepository, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(3, saved.size()); + org.junit.jupiter.api.Assertions.assertFalse(saved.contains(null)); + } + + @Test + @DisplayName("Should not save when all items are null") + void write_AllNullItems_NoSave() { + // Arrange + Chunk chunk = new Chunk<>(Collections.emptyList()); + + // Act + writer.write(chunk); + + // Assert + verify(recommendationRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("Should handle large chunk size") + void write_LargeChunk_SavesAll() { + // Arrange + int chunkSize = 100; + List items = new java.util.ArrayList<>(); + for (int i = 0; i < chunkSize; i++) { + RecommendationsActionPlan rec = new RecommendationsActionPlan(); + rec.setBasicProjectConfigId("project-" + i); + items.add(rec); + } + Chunk chunk = new Chunk<>(items); + + // Act + writer.write(chunk); + + // Assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(recommendationRepository, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(chunkSize, saved.size()); + } + } + + @Nested + @DisplayName("Trace Logging") + class TraceLogging { + + @Test + @DisplayName("Should log trace for each saved recommendation") + void write_MultipleRecommendations_LogsTraceForEach() { + // Arrange + Chunk chunk = new Chunk<>( + Arrays.asList(recommendation1, recommendation2, recommendation3)); + + // Act + writer.write(chunk); + + // Assert + verify(processorExecutionTraceLogService, times(3)).upsertTraceLog(eq("recommendation-calculation"), anyString(), + eq(true), eq(null)); + } + + @Test + @DisplayName("Should log trace with correct project IDs") + void write_Recommendations_LogsWithCorrectProjectIds() { + // Arrange + Chunk chunk = new Chunk<>(Arrays.asList(recommendation1, recommendation2)); + + // Act + writer.write(chunk); + + // Assert + verify(processorExecutionTraceLogService).upsertTraceLog(eq("recommendation-calculation"), eq("project-1"), eq(true), + eq(null)); + verify(processorExecutionTraceLogService).upsertTraceLog(eq("recommendation-calculation"), eq("project-2"), eq(true), + eq(null)); + } + + @Test + @DisplayName("Should not log trace when chunk is empty") + void write_EmptyChunk_NoTraceLog() { + // Arrange + Chunk chunk = new Chunk<>(Collections.emptyList()); + + // Act + writer.write(chunk); + + // Assert + verify(processorExecutionTraceLogService, never()).upsertTraceLog(anyString(), anyString(), eq(true), + eq(null)); + } + + @Test + @DisplayName("Should only log trace for non-null items") + void write_ChunkWithNulls_LogsOnlyForNonNulls() { + // Arrange + Chunk chunk = new Chunk<>(Arrays.asList(recommendation1, recommendation2)); + + // Act + writer.write(chunk); + + // Assert + verify(processorExecutionTraceLogService, times(2)).upsertTraceLog(eq("recommendation-calculation"), anyString(), + eq(true), eq(null)); + } + + @Test + @DisplayName("Should log trace with success=true") + void write_Recommendations_LogsWithSuccessTrue() { + // Arrange + Chunk chunk = new Chunk<>(Collections.singletonList(recommendation1)); + + // Act + writer.write(chunk); + + // Assert + verify(processorExecutionTraceLogService).upsertTraceLog(anyString(), anyString(), eq(true), eq(null)); + } + + @Test + @DisplayName("Should log trace with null error message") + void write_Recommendations_LogsWithNullError() { + // Arrange + Chunk chunk = new Chunk<>(Collections.singletonList(recommendation1)); + + // Act + writer.write(chunk); + + // Assert + verify(processorExecutionTraceLogService).upsertTraceLog(anyString(), anyString(), eq(true), eq(null)); + } + } + + @Nested + @DisplayName("Edge Cases") + class EdgeCases { + + @Test + @DisplayName("Should handle recommendation with null project ID gracefully") + void write_NullProjectId_HandlesGracefully() { + // Arrange + RecommendationsActionPlan recWithNullId = new RecommendationsActionPlan(); + recWithNullId.setBasicProjectConfigId(null); + Chunk chunk = new Chunk<>(Collections.singletonList(recWithNullId)); + + // Act + writer.write(chunk); + + // Assert + verify(recommendationRepository, times(1)).saveAll(anyList()); + verify(processorExecutionTraceLogService).upsertTraceLog(eq("recommendation-calculation"), eq(null), eq(true), + eq(null)); + } + + @Test + @DisplayName("Should handle recommendation with empty project ID") + void write_EmptyProjectId_HandlesGracefully() { + // Arrange + RecommendationsActionPlan recWithEmptyId = new RecommendationsActionPlan(); + recWithEmptyId.setBasicProjectConfigId(""); + Chunk chunk = new Chunk<>(Collections.singletonList(recWithEmptyId)); + + // Act + writer.write(chunk); + + // Assert + verify(recommendationRepository, times(1)).saveAll(anyList()); + verify(processorExecutionTraceLogService).upsertTraceLog(eq("recommendation-calculation"), eq(""), eq(true), eq(null)); + } + + @Test + @DisplayName("Should handle valid recommendations") + void write_MixedNullAndValid_ProcessesValidOnes() { + // Arrange + Chunk chunk = new Chunk<>(Arrays.asList(recommendation1, recommendation2)); + + // Act + writer.write(chunk); + + // Assert + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(recommendationRepository, times(1)).saveAll(captor.capture()); + + List saved = captor.getValue(); + org.junit.jupiter.api.Assertions.assertEquals(2, saved.size()); + verify(processorExecutionTraceLogService, times(2)).upsertTraceLog(anyString(), anyString(), eq(true), + eq(null)); + } + } + + @Nested + @DisplayName("Integration Behavior") + class IntegrationBehavior { + + @Test + @DisplayName("Should call saveAll before logging traces") + void write_Recommendations_SavesBeforeLogging() { + // Arrange + Chunk chunk = new Chunk<>(Collections.singletonList(recommendation1)); + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(recommendationRepository, + processorExecutionTraceLogService); + + // Act + writer.write(chunk); + + // Assert + inOrder.verify(recommendationRepository).saveAll(anyList()); + inOrder.verify(processorExecutionTraceLogService).upsertTraceLog(anyString(), anyString(), eq(true), + eq(null)); + } + + @Test + @DisplayName("Should process all recommendations in sequence") + void write_MultipleRecommendations_ProcessesInSequence() { + // Arrange + Chunk chunk = new Chunk<>( + Arrays.asList(recommendation1, recommendation2, recommendation3)); + + // Act + writer.write(chunk); + + // Assert + verify(recommendationRepository, times(1)).saveAll(anyList()); + verify(processorExecutionTraceLogService, times(3)).upsertTraceLog(anyString(), anyString(), eq(true), + eq(null)); + } + } +} diff --git a/pom.xml b/pom.xml index 92ec6b518..401d89606 100644 --- a/pom.xml +++ b/pom.xml @@ -254,6 +254,11 @@ atlassian-public https://packages.atlassian.com/maven/repository/public + + github + GitHub Packages + https://maven.pkg.github.com/PublicisSapient/knowhow-ai-gateway-client +