diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/activity/ActivitySearchFiltersDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/activity/ActivitySearchFiltersDto.java new file mode 100644 index 000000000..ea1d2d948 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/activity/ActivitySearchFiltersDto.java @@ -0,0 +1,118 @@ +package ca.bc.gov.restapi.results.common.dto.activity; + +import ca.bc.gov.restapi.results.common.SilvaConstants; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.Objects; +import lombok.Getter; +import lombok.ToString; +import org.springframework.util.CollectionUtils; + +/** This class contains all possible filters when using the Activity Search API. */ +@Getter +@ToString +public class ActivitySearchFiltersDto { + + @Schema(type = "array", nullable = true) + private final List bases; + + @Schema(type = "array", nullable = true) + private final List techniques; + + @Schema(type = "array", nullable = true) + private final List methods; + + @Schema(type = "boolean", nullable = true) + private final Boolean isComplete; + + @Schema(type = "array", nullable = true) + private final List objectives; + + @Schema(type = "array", nullable = true) + private final List fundingSources; + + @Schema(type = "array", nullable = true) + private final List orgUnits; + + @Schema(type = "array", nullable = true) + private final List openingCategories; + + @Schema(type = "string") + private final String fileId; + + @Schema(type = "array", nullable = true) + private final List clientNumbers; + + @Schema(type = "array", nullable = true) + private final List openingStatuses; + + @Schema(type = "string", format = "date", nullable = true) + private final String updateDateStart; + + @Schema(type = "string", format = "date", nullable = true) + private final String updateDateEnd; + + /** + * Creates a no-arg instance with all fields set to null, delegating to the all-args constructor + * to apply defaults. + */ + public ActivitySearchFiltersDto() { + this(null, null, null, null, null, null, null, null, null, null, null, null, null); + } + + /** Creates an instance of the activity search filter dto. */ + public ActivitySearchFiltersDto( + List bases, + List techniques, + List methods, + Boolean isComplete, + List objectives, + List fundingSources, + List orgUnits, + List openingCategories, + String fileId, + List clientNumbers, + List openingStatuses, + String updateDateStart, + String updateDateEnd) { + this.bases = !CollectionUtils.isEmpty(bases) ? bases : List.of(SilvaConstants.NOVALUE); + this.techniques = + !CollectionUtils.isEmpty(techniques) ? techniques : List.of(SilvaConstants.NOVALUE); + this.methods = !CollectionUtils.isEmpty(methods) ? methods : List.of(SilvaConstants.NOVALUE); + this.isComplete = isComplete; + this.objectives = + !CollectionUtils.isEmpty(objectives) ? objectives : List.of(SilvaConstants.NOVALUE); + this.fundingSources = + !CollectionUtils.isEmpty(fundingSources) ? fundingSources : List.of(SilvaConstants.NOVALUE); + this.orgUnits = !CollectionUtils.isEmpty(orgUnits) ? orgUnits : List.of(SilvaConstants.NOVALUE); + this.openingCategories = + !CollectionUtils.isEmpty(openingCategories) + ? openingCategories + : List.of(SilvaConstants.NOVALUE); + this.fileId = Objects.isNull(fileId) ? null : fileId.trim(); + this.clientNumbers = + !CollectionUtils.isEmpty(clientNumbers) ? clientNumbers : List.of(SilvaConstants.NOVALUE); + this.openingStatuses = + !CollectionUtils.isEmpty(openingStatuses) + ? openingStatuses + : List.of(SilvaConstants.NOVALUE); + this.updateDateStart = Objects.isNull(updateDateStart) ? null : updateDateStart.trim(); + this.updateDateEnd = Objects.isNull(updateDateEnd) ? null : updateDateEnd.trim(); + } + + public boolean hasAnyFilter() { + return !SilvaConstants.NOVALUE.equals(bases.get(0)) + || !SilvaConstants.NOVALUE.equals(techniques.get(0)) + || !SilvaConstants.NOVALUE.equals(methods.get(0)) + || isComplete != null + || !SilvaConstants.NOVALUE.equals(objectives.get(0)) + || !SilvaConstants.NOVALUE.equals(fundingSources.get(0)) + || !SilvaConstants.NOVALUE.equals(orgUnits.get(0)) + || !SilvaConstants.NOVALUE.equals(openingCategories.get(0)) + || (fileId != null && !fileId.isBlank()) + || !SilvaConstants.NOVALUE.equals(clientNumbers.get(0)) + || !SilvaConstants.NOVALUE.equals(openingStatuses.get(0)) + || (updateDateStart != null && !updateDateStart.isBlank()) + || (updateDateEnd != null && !updateDateEnd.isBlank()); + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/activity/ActivitySearchResponseDto.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/activity/ActivitySearchResponseDto.java new file mode 100644 index 000000000..87b15dff7 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/dto/activity/ActivitySearchResponseDto.java @@ -0,0 +1,24 @@ +package ca.bc.gov.restapi.results.common.dto.activity; + +import ca.bc.gov.restapi.results.common.dto.CodeDescriptionDto; +import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +public record ActivitySearchResponseDto( + Long activityId, + CodeDescriptionDto base, + CodeDescriptionDto technique, + CodeDescriptionDto method, + boolean isComplete, + CodeDescriptionDto fundingSource, + String fileId, + String cutBlock, + Long openingId, + String cuttingPermit, + BigDecimal treatmentAmountArea, + String intraAgencyNumber, + CodeDescriptionDto openingCategory, + CodeDescriptionDto orgUnit, + ForestClientDto openingClient, + LocalDateTime updateTimestamp) {} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/SearchEndpoint.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/SearchEndpoint.java index 8351fd3f0..f6c8cbf38 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/SearchEndpoint.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/endpoint/SearchEndpoint.java @@ -1,8 +1,11 @@ package ca.bc.gov.restapi.results.common.endpoint; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchResponseDto; import ca.bc.gov.restapi.results.common.dto.opening.OpeningSearchExactFiltersDto; import ca.bc.gov.restapi.results.common.dto.opening.OpeningSearchResponseDto; import ca.bc.gov.restapi.results.common.exception.MissingSearchParameterException; +import ca.bc.gov.restapi.results.common.service.ActivityService; import ca.bc.gov.restapi.results.common.service.OpeningSearchService; import ca.bc.gov.restapi.results.oracle.SilvaOracleConstants; import java.util.List; @@ -24,6 +27,8 @@ public class SearchEndpoint { private final OpeningSearchService openingSearchService; + private final ActivityService activityService; + /** * Exact search for Openings with direct value matching on provided filters. * @@ -121,4 +126,44 @@ public Page openingSearchExact( return openingSearchService.openingSearchExact(filtersDto, paginationParameters); } + + @GetMapping("/activities") + public Page activitySearch( + @RequestParam(value = "bases", required = false) List bases, + @RequestParam(value = "techniques", required = false) List techniques, + @RequestParam(value = "methods", required = false) List methods, + @RequestParam(value = "isComplete", required = false) Boolean isComplete, + @RequestParam(value = "objectives", required = false) List objectives, + @RequestParam(value = "fundingSources", required = false) List fundingSources, + @RequestParam(value = "orgUnits", required = false) List orgUnits, + @RequestParam(value = "openingCategories", required = false) List openingCategories, + @RequestParam(value = "fileId", required = false) String fileId, + @RequestParam(value = "clientNumbers", required = false) List clientNumbers, + @RequestParam(value = "openingStatuses", required = false) List openingStatuses, + @RequestParam(value = "updateDateStart", required = false) String updateDateStart, + @RequestParam(value = "updateDateEnd", required = false) String updateDateEnd, + @ParameterObject Pageable paginationParameters) { + + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + bases, + techniques, + methods, + isComplete, + objectives, + fundingSources, + orgUnits, + openingCategories, + fileId, + clientNumbers, + openingStatuses, + updateDateStart, + updateDateEnd); + + if (!filters.hasAnyFilter()) { + throw new MissingSearchParameterException(); + } + + return activityService.activitySearch(filters, paginationParameters); + } } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/projection/ActivitySearchProjection.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/projection/ActivitySearchProjection.java new file mode 100644 index 000000000..2e4331080 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/projection/ActivitySearchProjection.java @@ -0,0 +1,54 @@ +package ca.bc.gov.restapi.results.common.projection; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** Projection interface used for activity search native queries. */ +public interface ActivitySearchProjection { + + Long getActivityId(); + + String getBaseCode(); + + String getBaseDescription(); + + String getTechniqueCode(); + + String getTechniqueDescription(); + + String getMethodCode(); + + String getMethodDescription(); + + Long getIsComplete(); + + String getFundingSourceCode(); + + String getFundingSourceDescription(); + + String getFileId(); + + String getCutBlock(); + + Long getOpeningId(); + + String getCuttingPermit(); + + BigDecimal getTreatmentAmountArea(); + + String getIntraAgencyNumber(); + + String getOpeningCategoryCode(); + + String getOpeningCategoryDescription(); + + String getOrgUnitCode(); + + String getOrgUnitDescription(); + + String getOpeningClientCode(); + + Long getTotalCount(); + + LocalDateTime getUpdateTimestamp(); +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/repository/ActivityTreatmentUnitRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/repository/ActivityTreatmentUnitRepository.java index 2cf7481ac..172876572 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/repository/ActivityTreatmentUnitRepository.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/repository/ActivityTreatmentUnitRepository.java @@ -1,13 +1,14 @@ package ca.bc.gov.restapi.results.common.repository; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.projection.ActivitySearchProjection; import ca.bc.gov.restapi.results.common.projection.activity.*; import ca.bc.gov.restapi.results.common.projection.opening.OpeningActivitiesActivitiesProjection; import ca.bc.gov.restapi.results.common.projection.opening.OpeningActivitiesDisturbanceProjection; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; public interface ActivityTreatmentUnitRepository { Page getOpeningActivitiesDisturbanceByOpeningId( @@ -27,4 +28,7 @@ Page getOpeningActivitiesActivitiesByOpen Optional getOpeningActivityPR(Long openingId, Long atuId); Optional getOpeningActivitySP(Long openingId, Long atuId); + + List activitySearch( + ActivitySearchFiltersDto filters, long offset, long size); } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ActivityService.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ActivityService.java new file mode 100644 index 000000000..0850b21b0 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/ActivityService.java @@ -0,0 +1,11 @@ +package ca.bc.gov.restapi.results.common.service; + +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchResponseDto; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ActivityService { + Page activitySearch( + ActivitySearchFiltersDto filters, Pageable pagination); +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractActivityService.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractActivityService.java new file mode 100644 index 000000000..2240660a8 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractActivityService.java @@ -0,0 +1,101 @@ +package ca.bc.gov.restapi.results.common.service.impl; + +import ca.bc.gov.restapi.results.common.dto.CodeDescriptionDto; +import ca.bc.gov.restapi.results.common.dto.ForestClientDto; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchResponseDto; +import ca.bc.gov.restapi.results.common.projection.ActivitySearchProjection; +import ca.bc.gov.restapi.results.common.repository.ActivityTreatmentUnitRepository; +import ca.bc.gov.restapi.results.common.service.ActivityService; +import ca.bc.gov.restapi.results.common.service.ForestClientService; +import ca.bc.gov.restapi.results.common.util.DateUtil; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +@Slf4j +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class AbstractActivityService implements ActivityService { + + protected ActivityTreatmentUnitRepository activityTreatmentUnitRepository; + protected ForestClientService forestClientService; + + @Override + public Page activitySearch( + ActivitySearchFiltersDto filters, Pageable pagination) { + DateUtil.validateDateRange(filters.getUpdateDateStart(), filters.getUpdateDateEnd()); + + long offset = pagination.getOffset(); + long size = pagination.getPageSize(); + + List projections = + activityTreatmentUnitRepository.activitySearch(filters, offset, size); + + long total = 0; + if (!projections.isEmpty()) { + Long totalCount = projections.get(0).getTotalCount(); + total = totalCount != null ? totalCount : 0; + } + + // Gather unique client numbers and fetch client information + List clientNumbers = + projections.stream() + .map(ActivitySearchProjection::getOpeningClientCode) + .filter(code -> code != null && !code.isBlank()) + .distinct() + .collect(Collectors.toList()); + + final Map clientMap; + if (!clientNumbers.isEmpty()) { + List clients = + forestClientService.searchByClientNumbers(0, clientNumbers.size(), clientNumbers); + clientMap = clients.stream().collect(Collectors.toMap(ForestClientDto::clientNumber, c -> c)); + } else { + clientMap = new HashMap<>(); + } + + // Map projections to response DTOs + var responseDtos = + projections.stream() + .map(projection -> mapToSearchResponse(projection, clientMap)) + .collect(Collectors.toList()); + + return new PageImpl<>(responseDtos, pagination, total); + } + + private ActivitySearchResponseDto mapToSearchResponse( + ActivitySearchProjection projection, Map clientMap) { + + return new ActivitySearchResponseDto( + projection.getActivityId(), + mapCode(projection.getBaseCode(), projection.getBaseDescription()), + mapCode(projection.getTechniqueCode(), projection.getTechniqueDescription()), + mapCode(projection.getMethodCode(), projection.getMethodDescription()), + projection.getIsComplete() != null && projection.getIsComplete() == 1L, + mapCode(projection.getFundingSourceCode(), projection.getFundingSourceDescription()), + projection.getFileId(), + projection.getCutBlock(), + projection.getOpeningId(), + projection.getCuttingPermit(), + projection.getTreatmentAmountArea(), + projection.getIntraAgencyNumber(), + mapCode(projection.getOpeningCategoryCode(), projection.getOpeningCategoryDescription()), + mapCode(projection.getOrgUnitCode(), projection.getOrgUnitDescription()), + clientMap.getOrDefault(projection.getOpeningClientCode(), null), + projection.getUpdateTimestamp()); + } + + private CodeDescriptionDto mapCode(String code, String description) { + if (code == null || code.isBlank()) { + return null; + } + return new CodeDescriptionDto(code, description); + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractOpeningSearchService.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractOpeningSearchService.java index 1b775198c..a0fddfca3 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractOpeningSearchService.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/service/impl/AbstractOpeningSearchService.java @@ -13,10 +13,9 @@ import ca.bc.gov.restapi.results.common.security.LoggedUserHelper; import ca.bc.gov.restapi.results.common.service.CodeService; import ca.bc.gov.restapi.results.common.service.OpeningSearchService; +import ca.bc.gov.restapi.results.common.util.DateUtil; import ca.bc.gov.restapi.results.postgres.service.UserOpeningService; import jakarta.transaction.Transactional; -import java.time.LocalDate; -import java.time.format.DateTimeParseException; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -60,7 +59,7 @@ public Page openingSearchExact( filtersDto); validatePageSize(pagination); - validateEntryDateRange(filtersDto); + DateUtil.validateDateRange(filtersDto.getUpdateDateStart(), filtersDto.getUpdateDateEnd()); // Validate mapsheet related fields for Opening Number validateMapsheetFields(filtersDto); @@ -248,23 +247,6 @@ private void validatePageSize(Pageable pagination) { } } - private void validateEntryDateRange(OpeningSearchExactFiltersDto filtersDto) { - if (filtersDto.getUpdateDateStart() != null && filtersDto.getUpdateDateEnd() != null) { - try { - LocalDate start = LocalDate.parse(filtersDto.getUpdateDateStart()); - LocalDate end = LocalDate.parse(filtersDto.getUpdateDateEnd()); - if (end.isBefore(start)) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "End date must be the same or after start date"); - } - } catch (DateTimeParseException ex) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "Invalid date format for updateDateStart/updateDateEnd. Expected yyyy-MM-dd"); - } - } - } - /** * Consolidated validation for all mapsheet-related fields. * diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/common/util/DateUtil.java b/backend/src/main/java/ca/bc/gov/restapi/results/common/util/DateUtil.java new file mode 100644 index 000000000..4317d1a25 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/common/util/DateUtil.java @@ -0,0 +1,37 @@ +package ca.bc.gov.restapi.results.common.util; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class DateUtil { + + /** + * Validates that the end date is the same or after the start date. + * + * @param start the start date string (yyyy-MM-dd format) + * @param end the end date string (yyyy-MM-dd format) + * @throws ResponseStatusException if inputs are invalid (bad format) or end date is before start + * date + */ + public static void validateDateRange(String start, String end) { + if (start == null || end == null) { + return; + } + try { + LocalDate startDate = LocalDate.parse(start); + LocalDate endDate = LocalDate.parse(end); + if (endDate.isBefore(startDate)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "End date must be the same or after start date"); + } + } catch (DateTimeParseException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Invalid date format. Expected yyyy-MM-dd"); + } + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleQueryConstants.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleQueryConstants.java index 3d2add0ea..4b03e1ab4 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleQueryConstants.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/SilvaOracleQueryConstants.java @@ -1677,4 +1677,138 @@ AND TRUNC(fr.ARCHIVE_DATE) = TO_DATE(:archiveDate, 'YYYY-MM-DD') + SILVICULTURE_SEARCH_CTE_SELECT + " FROM silviculture_search ORDER BY opening_id DESC " + PAGINATION; + + public static final String ACTIVITY_SEARCH = + """ + WITH activity_search AS ( + SELECT + atu.ACTIVITY_TREATMENT_UNIT_ID AS activityId, + sbc.SILV_BASE_CODE AS baseCode, + sbc.DESCRIPTION AS baseDescription, + stc.SILV_TECHNIQUE_CODE AS techniqueCode, + stc.DESCRIPTION AS techniqueDescription, + smc.SILV_METHOD_CODE AS methodCode, + smc.DESCRIPTION AS methodDescription, + CASE WHEN atu.ATU_COMPLETION_DATE IS NOT NULL THEN 1 ELSE 0 END AS isComplete, + sfsc.SILV_FUND_SRCE_CODE AS fundingSourceCode, + sfsc.DESCRIPTION AS fundingSourceDescription, + cboa.FOREST_FILE_ID AS fileId, + cboa.CUT_BLOCK_ID AS cutBlock, + atu.OPENING_ID AS openingId, + cboa.CUTTING_PERMIT_ID AS cuttingPermit, + atu.TREATMENT_AMOUNT AS treatmentAmountArea, + atu.ACTIVITY_LICENSEE_ID AS intraAgencyNumber, + occ.OPEN_CATEGORY_CODE AS openingCategoryCode, + occ.DESCRIPTION AS openingCategoryDescription, + ou.ORG_UNIT_CODE AS orgUnitCode, + ou.ORG_UNIT_NAME AS orgUnitDescription, + ffc.CLIENT_NUMBER AS openingClientCode, + COUNT(*) OVER () AS totalCount, + atu.UPDATE_TIMESTAMP AS updateTimestamp + FROM ACTIVITY_TREATMENT_UNIT atu + LEFT JOIN OPENING op ON atu.OPENING_ID = op.OPENING_ID + LEFT JOIN ORG_UNIT ou ON atu.ORG_UNIT_NO = ou.ORG_UNIT_NO + LEFT JOIN CUT_BLOCK_OPEN_ADMIN cboa ON op.OPENING_ID = cboa.OPENING_ID AND cboa.CUT_BLOCK_OPEN_ADMIN_ID = ( + SELECT MAX(CUT_BLOCK_OPEN_ADMIN_ID) FROM CUT_BLOCK_OPEN_ADMIN cboa2 + WHERE cboa2.OPENING_ID = op.OPENING_ID + ) + LEFT JOIN SILV_BASE_CODE sbc ON atu.SILV_BASE_CODE = sbc.SILV_BASE_CODE + LEFT JOIN SILV_TECHNIQUE_CODE stc ON atu.SILV_TECHNIQUE_CODE = stc.SILV_TECHNIQUE_CODE + LEFT JOIN SILV_METHOD_CODE smc ON atu.SILV_METHOD_CODE = smc.SILV_METHOD_CODE + LEFT JOIN SILV_FUND_SRCE_CODE sfsc ON atu.SILV_FUND_SRCE_CODE = sfsc.SILV_FUND_SRCE_CODE + LEFT JOIN OPEN_CATEGORY_CODE occ ON op.OPEN_CATEGORY_CODE = occ.OPEN_CATEGORY_CODE + LEFT JOIN FOREST_FILE_CLIENT ffc ON (cboa.FOREST_FILE_ID = ffc.FOREST_FILE_ID AND ffc.FOREST_FILE_CLIENT_TYPE_CODE = 'A') + WHERE + ( + 'NOVALUE' IN (:#{#filter.bases}) OR atu.SILV_BASE_CODE IN (:#{#filter.bases}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.techniques}) OR atu.SILV_TECHNIQUE_CODE IN (:#{#filter.techniques}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.methods}) OR atu.SILV_METHOD_CODE IN (:#{#filter.methods}) + ) + AND ( + CASE + WHEN :#{#filter.isComplete} IS NULL THEN 1 + WHEN :#{#filter.isComplete} = true THEN CASE WHEN atu.ATU_COMPLETION_DATE IS NOT NULL THEN 1 ELSE 0 END + WHEN :#{#filter.isComplete} = false THEN CASE WHEN atu.ATU_COMPLETION_DATE IS NULL THEN 1 ELSE 0 END + ELSE 1 + END = 1 + ) + AND ( + 'NOVALUE' IN (:#{#filter.objectives}) + OR atu.SILV_OBJECTIVE_CODE_1 IN (:#{#filter.objectives}) + OR atu.SILV_OBJECTIVE_CODE_2 IN (:#{#filter.objectives}) + OR atu.SILV_OBJECTIVE_CODE_3 IN (:#{#filter.objectives}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.fundingSources}) OR atu.SILV_FUND_SRCE_CODE IN (:#{#filter.fundingSources}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.orgUnits}) OR ou.ORG_UNIT_CODE IN (:#{#filter.orgUnits}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.openingCategories}) OR op.OPEN_CATEGORY_CODE IN (:#{#filter.openingCategories}) + ) + AND ( + NVL(:#{#filter.fileId},'NOVALUE') = 'NOVALUE' OR cboa.FOREST_FILE_ID = :#{#filter.fileId} + ) + AND ( + 'NOVALUE' IN (:#{#filter.clientNumbers}) OR ffc.CLIENT_NUMBER IN (:#{#filter.clientNumbers}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.openingStatuses}) OR op.OPENING_STATUS_CODE IN (:#{#filter.openingStatuses}) + ) + AND ( + ( + NVL(:#{#filter.updateDateStart},'NOVALUE') = 'NOVALUE' AND NVL(:#{#filter.updateDateEnd},'NOVALUE') = 'NOVALUE' + ) + OR ( + atu.UPDATE_TIMESTAMP IS NOT NULL + AND ( + ( + NVL(:#{#filter.updateDateStart},'NOVALUE') != 'NOVALUE' + AND TRUNC(atu.UPDATE_TIMESTAMP) >= TO_DATE(:#{#filter.updateDateStart},'YYYY-MM-DD') + ) + OR NVL(:#{#filter.updateDateStart},'NOVALUE') = 'NOVALUE' + ) + AND ( + ( + NVL(:#{#filter.updateDateEnd},'NOVALUE') != 'NOVALUE' + AND TRUNC(atu.UPDATE_TIMESTAMP) < TO_DATE(:#{#filter.updateDateEnd},'YYYY-MM-DD') + 1 + ) + OR NVL(:#{#filter.updateDateEnd},'NOVALUE') = 'NOVALUE' + ) + ) + ) + ) + SELECT + activityId, + baseCode, + baseDescription, + techniqueCode, + techniqueDescription, + methodCode, + methodDescription, + isComplete, + fundingSourceCode, + fundingSourceDescription, + fileId, + cutBlock, + openingId, + cuttingPermit, + treatmentAmountArea, + intraAgencyNumber, + openingCategoryCode, + openingCategoryDescription, + orgUnitCode, + orgUnitDescription, + openingClientCode, + totalCount, + updateTimestamp + FROM activity_search + ORDER BY activityId DESC + """ + + PAGINATION; } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/ActivityTreatmentUnitOracleRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/ActivityTreatmentUnitOracleRepository.java index dbe504340..a11fa0a28 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/ActivityTreatmentUnitOracleRepository.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/repository/ActivityTreatmentUnitOracleRepository.java @@ -1,8 +1,7 @@ package ca.bc.gov.restapi.results.oracle.repository; -import ca.bc.gov.restapi.results.common.repository.ActivityTreatmentUnitRepository; -import ca.bc.gov.restapi.results.oracle.SilvaOracleQueryConstants; -import ca.bc.gov.restapi.results.oracle.entity.activities.ActivityTreatmentUnitEntity; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.projection.ActivitySearchProjection; import ca.bc.gov.restapi.results.common.projection.activity.OpeningActivityBaseProjection; import ca.bc.gov.restapi.results.common.projection.activity.OpeningActivityJuvenileProjection; import ca.bc.gov.restapi.results.common.projection.activity.OpeningActivityPruningProjection; @@ -10,14 +9,17 @@ import ca.bc.gov.restapi.results.common.projection.activity.OpeningActivitySurveyProjection; import ca.bc.gov.restapi.results.common.projection.opening.OpeningActivitiesActivitiesProjection; import ca.bc.gov.restapi.results.common.projection.opening.OpeningActivitiesDisturbanceProjection; +import ca.bc.gov.restapi.results.common.repository.ActivityTreatmentUnitRepository; +import ca.bc.gov.restapi.results.oracle.SilvaOracleQueryConstants; +import ca.bc.gov.restapi.results.oracle.entity.activities.ActivityTreatmentUnitEntity; import java.util.List; import java.util.Optional; - import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository @@ -64,4 +66,11 @@ Page getOpeningActivitiesActivitiesByOpen @Override @Query(nativeQuery = true, value = SilvaOracleQueryConstants.GET_OPENING_ACTIVITY_SP) Optional getOpeningActivitySP(Long openingId, Long atuId); + + @Override + @Query(nativeQuery = true, value = SilvaOracleQueryConstants.ACTIVITY_SEARCH) + List activitySearch( + @Param("filter") ActivitySearchFiltersDto filter, + @Param("page") long offset, + @Param("size") long size); } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/ActivityOracleService.java b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/ActivityOracleService.java new file mode 100644 index 000000000..9f7d363e5 --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/oracle/service/ActivityOracleService.java @@ -0,0 +1,18 @@ +package ca.bc.gov.restapi.results.oracle.service; + +import ca.bc.gov.restapi.results.common.service.ForestClientService; +import ca.bc.gov.restapi.results.common.service.impl.AbstractActivityService; +import ca.bc.gov.restapi.results.oracle.repository.ActivityTreatmentUnitOracleRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@ConditionalOnProperty(prefix = "server", name = "primary-db", havingValue = "oracle") +@Service +public class ActivityOracleService extends AbstractActivityService { + + public ActivityOracleService( + ActivityTreatmentUnitOracleRepository activityTreatmentUnitRepository, + ForestClientService forestClientService) { + super(activityTreatmentUnitRepository, forestClientService); + } +} diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/SilvaPostgresQueryConstants.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/SilvaPostgresQueryConstants.java index 1e354d668..2471ae2ef 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/SilvaPostgresQueryConstants.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/SilvaPostgresQueryConstants.java @@ -345,7 +345,7 @@ LEFT JOIN org_unit outsb ON (outsb.org_unit_no = (SELECT hs.bcts_org_unit FROM s LEFT JOIN file_type_code ftc ON (pfu.file_type_code = ftc.file_type_code) LEFT JOIN mgmt_unit_type_code mutc ON (pfu.mgmt_unit_type = mutc.mgmt_unit_type_code) WHERE op.opening_id = :openingId"""; - + public static final String GET_OPENING_OVERVIEW_MILESTONE = """ SELECT @@ -1662,5 +1662,135 @@ AND DATE(fr.archive_date) = TO_DATE(:archiveDate, 'YYYY-MM-DD') + SILVICULTURE_SEARCH_CTE_SELECT + " FROM silviculture_search ORDER BY opening_id DESC " + PAGINATION; + + + public static final String ACTIVITY_SEARCH = """ + WITH activity_search AS ( + SELECT + atu.activity_treatment_unit_id AS activityId, + sbc.silv_base_code AS baseCode, + sbc.description AS baseDescription, + stc.silv_technique_code AS techniqueCode, + stc.description AS techniqueDescription, + smc.silv_method_code AS methodCode, + smc.description AS methodDescription, + CASE WHEN atu.atu_completion_date IS NOT NULL THEN 1 ELSE 0 END AS isComplete, + sfsc.silv_fund_srce_code AS fundingSourceCode, + sfsc.description AS fundingSourceDescription, + cboa.forest_file_id AS fileId, + cboa.cut_block_id AS cutBlock, + atu.opening_id AS openingId, + cboa.cutting_permit_id AS cuttingPermit, + atu.treatment_amount AS treatmentAmountArea, + atu.activity_licensee_id AS intraAgencyNumber, + occ.open_category_code AS openingCategoryCode, + occ.description AS openingCategoryDescription, + ou.org_unit_code AS orgUnitCode, + ou.org_unit_name AS orgUnitDescription, + ffc.client_number AS openingClientCode, + COUNT(*) OVER () AS totalCount, + atu.update_timestamp AS updateTimestamp + FROM activity_treatment_unit atu + LEFT JOIN opening op ON atu.opening_id = op.opening_id + LEFT JOIN org_unit ou ON atu.org_unit_no = ou.org_unit_no + LEFT JOIN cut_block_open_admin cboa ON op.opening_id = cboa.opening_id AND cboa.cut_block_open_admin_id = ( + SELECT MAX(cut_block_open_admin_id) FROM cut_block_open_admin cboa2 + WHERE cboa2.opening_id = op.opening_id + ) + LEFT JOIN silv_base_code sbc ON atu.silv_base_code = sbc.silv_base_code + LEFT JOIN silv_technique_code stc ON atu.silv_technique_code = stc.silv_technique_code + LEFT JOIN silv_method_code smc ON atu.silv_method_code = smc.silv_method_code + LEFT JOIN silv_fund_srce_code sfsc ON atu.silv_fund_srce_code = sfsc.silv_fund_srce_code + LEFT JOIN open_category_code occ ON op.open_category_code = occ.open_category_code + LEFT JOIN forest_file_client ffc ON (cboa.forest_file_id = ffc.forest_file_id AND ffc.forest_file_client_type_code = 'A') + WHERE + ( + 'NOVALUE' IN (:#{#filter.bases}) OR atu.silv_base_code IN (:#{#filter.bases}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.techniques}) OR atu.silv_technique_code IN (:#{#filter.techniques}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.methods}) OR atu.silv_method_code IN (:#{#filter.methods}) + ) + AND ( + CAST(:#{#filter.isComplete} AS boolean) IS NULL + OR (CAST(:#{#filter.isComplete} AS boolean) = true AND atu.atu_completion_date IS NOT NULL) + OR (CAST(:#{#filter.isComplete} AS boolean) = false AND atu.atu_completion_date IS NULL) + ) + AND ( + 'NOVALUE' IN (:#{#filter.objectives}) + OR atu.silv_objective_code_1 IN (:#{#filter.objectives}) + OR atu.silv_objective_code_2 IN (:#{#filter.objectives}) + OR atu.silv_objective_code_3 IN (:#{#filter.objectives}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.fundingSources}) OR atu.silv_fund_srce_code IN (:#{#filter.fundingSources}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.orgUnits}) OR ou.org_unit_code IN (:#{#filter.orgUnits}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.openingCategories}) OR op.open_category_code IN (:#{#filter.openingCategories}) + ) + AND ( + COALESCE(CAST(:#{#filter.fileId} AS text),'NOVALUE') = 'NOVALUE' OR cboa.forest_file_id = CAST(:#{#filter.fileId} AS text) + ) + AND ( + 'NOVALUE' IN (:#{#filter.clientNumbers}) OR ffc.client_number IN (:#{#filter.clientNumbers}) + ) + AND ( + 'NOVALUE' IN (:#{#filter.openingStatuses}) OR op.opening_status_code IN (:#{#filter.openingStatuses}) + ) + AND ( + ( + COALESCE(CAST(:#{#filter.updateDateStart} AS text),'NOVALUE') = 'NOVALUE' AND COALESCE(CAST(:#{#filter.updateDateEnd} AS text),'NOVALUE') = 'NOVALUE' + ) + OR ( + atu.update_timestamp IS NOT NULL + AND ( + ( + COALESCE(CAST(:#{#filter.updateDateStart} AS text),'NOVALUE') != 'NOVALUE' + AND atu.update_timestamp >= TO_DATE(CAST(:#{#filter.updateDateStart} AS text),'YYYY-MM-DD') + ) + OR COALESCE(CAST(:#{#filter.updateDateStart} AS text),'NOVALUE') = 'NOVALUE' + ) + AND ( + ( + COALESCE(CAST(:#{#filter.updateDateEnd} AS text),'NOVALUE') != 'NOVALUE' + AND atu.update_timestamp < TO_DATE(CAST(:#{#filter.updateDateEnd} AS text),'YYYY-MM-DD') + INTERVAL '1 day' + ) + OR COALESCE(CAST(:#{#filter.updateDateEnd} AS text),'NOVALUE') = 'NOVALUE' + ) + ) + ) + ) + SELECT + activityId, + baseCode, + baseDescription, + techniqueCode, + techniqueDescription, + methodCode, + methodDescription, + isComplete, + fundingSourceCode, + fundingSourceDescription, + fileId, + cutBlock, + openingId, + cuttingPermit, + treatmentAmountArea, + intraAgencyNumber, + openingCategoryCode, + openingCategoryDescription, + orgUnitCode, + orgUnitDescription, + openingClientCode, + totalCount, + updateTimestamp + FROM activity_search + ORDER BY activityId DESC + """ + PAGINATION; } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/ActivityTreatmentUnitPostgresRepository.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/ActivityTreatmentUnitPostgresRepository.java index 29823ea82..cad8ef534 100644 --- a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/ActivityTreatmentUnitPostgresRepository.java +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/repository/ActivityTreatmentUnitPostgresRepository.java @@ -1,21 +1,23 @@ package ca.bc.gov.restapi.results.postgres.repository; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.projection.ActivitySearchProjection; import ca.bc.gov.restapi.results.common.projection.activity.*; import ca.bc.gov.restapi.results.common.projection.opening.OpeningActivitiesActivitiesProjection; import ca.bc.gov.restapi.results.common.projection.opening.OpeningActivitiesDisturbanceProjection; import ca.bc.gov.restapi.results.common.repository.ActivityTreatmentUnitRepository; import ca.bc.gov.restapi.results.postgres.SilvaPostgresQueryConstants; import ca.bc.gov.restapi.results.postgres.entity.activity.ActivityTreatmentUnitEntity; +import java.util.List; +import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.List; -import java.util.Optional; - /** * Repository interface for CRUD operations and custom queries against the * `silva.activity_treatment_unit` table in PostgreSQL. @@ -64,4 +66,11 @@ Page getOpeningActivitiesActivitiesByOpen @Override @Query(nativeQuery = true, value = SilvaPostgresQueryConstants.GET_OPENING_ACTIVITY_SP) Optional getOpeningActivitySP(Long openingId, Long atuId); + + @Override + @Query(nativeQuery = true, value = SilvaPostgresQueryConstants.ACTIVITY_SEARCH) + List activitySearch( + @Param("filter") ActivitySearchFiltersDto filter, + @Param("page") long offset, + @Param("size") long size); } diff --git a/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/ActivityPostgresService.java b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/ActivityPostgresService.java new file mode 100644 index 000000000..42623915e --- /dev/null +++ b/backend/src/main/java/ca/bc/gov/restapi/results/postgres/service/ActivityPostgresService.java @@ -0,0 +1,17 @@ +package ca.bc.gov.restapi.results.postgres.service; + +import ca.bc.gov.restapi.results.common.service.ForestClientService; +import ca.bc.gov.restapi.results.common.service.impl.AbstractActivityService; +import ca.bc.gov.restapi.results.postgres.repository.ActivityTreatmentUnitPostgresRepository; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Service +@ConditionalOnProperty(prefix = "server", name = "primary-db", havingValue = "postgres") +public class ActivityPostgresService extends AbstractActivityService { + public ActivityPostgresService( + ActivityTreatmentUnitPostgresRepository activityTreatmentUnitRepository, + ForestClientService forestClientService) { + super(activityTreatmentUnitRepository, forestClientService); + } +} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/AbstractSearchEndpointActivitySearchIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/AbstractSearchEndpointActivitySearchIntegrationTest.java new file mode 100644 index 000000000..18a7860d4 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/endpoint/AbstractSearchEndpointActivitySearchIntegrationTest.java @@ -0,0 +1,203 @@ +package ca.bc.gov.restapi.results.common.endpoint; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WiremockLogNotifier; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Abstract integration test for activity search endpoints. Defines test contract for all + * SearchEndpoint activitySearch implementations (Postgres and Oracle). + */ +@WithMockJwt(value = "ttester") +@AutoConfigureMockMvc +@DisplayName("Integrated Test | Activity Search Endpoint | Contract") +public abstract class AbstractSearchEndpointActivitySearchIntegrationTest + extends AbstractTestContainerIntegrationTest { + + @RegisterExtension + static WireMockExtension clientApiStub = + WireMockExtension.newInstance() + .options( + wireMockConfig() + .port(10000) + .notifier(new WiremockLogNotifier()) + .asynchronousResponseEnabled(true) + .stubRequestLoggingDisabled(false)) + .configureStaticDsl(true) + .build(); + + @Autowired protected MockMvc mockMvc; + + @Test + @DisplayName("GET /api/search/activities with default parameters should succeed") + void getActivities_withDefaultParameters_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("bases", "PR") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page").exists()) + .andExpect(jsonPath("$.page.totalElements").exists()) + .andExpect(jsonPath("$.page.totalPages").exists()); + } + + @Test + @DisplayName("GET /api/search/activities with file ID filter should succeed") + void getActivities_withFileIdFilter_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("fileId", "TFL47") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page").exists()); + } + + @Test + @DisplayName("GET /api/search/activities with base code filter should succeed") + void getActivities_withBaseCodeFilter_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("bases", "PR") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @DisplayName("GET /api/search/activities with multiple base codes should succeed") + void getActivities_withMultipleBaseCodes_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("bases", "PR") + .param("bases", "SP") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @DisplayName("GET /api/search/activities with technique filter should succeed") + void getActivities_withTechniqueFilter_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("techniques", "DEC") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @DisplayName("GET /api/search/activities with pagination should succeed") + void getActivities_withPagination_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("bases", "PR") + .param("page", "0") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.page.size").value(10)); + } + + @Test + @DisplayName("GET /api/search/activities with sorting should succeed") + void getActivities_withSorting_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("bases", "PR") + .param("sort", "silvBaseCode,desc") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @DisplayName("GET /api/search/activities response should contain required fields") + void getActivities_response_shouldContainRequiredFields() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("fileId", "TFL47") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].openingId").exists()) + .andExpect(jsonPath("$.content[0].activityId").exists()) + .andExpect(jsonPath("$.content[0].base").exists()); + } + + @Test + @DisplayName("GET /api/search/activities with combined filters should succeed") + void getActivities_withCombinedFilters_shouldSucceed() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("fileId", "TFL47") + .param("bases", "PR") + .param("page", "0") + .param("size", "20") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.page.size").value(20)); + } + + @Test + @DisplayName("GET /api/search/activities with invalid file ID should return empty") + void getActivities_withInvalidFileId_shouldReturnEmpty() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("fileId", "INVALID_FILE_999999") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.totalElements").value(0)); + } + + @Test + @DisplayName("GET /api/search/activities response should have pagination metadata") + void getActivities_response_shouldHavePaginationMetadata() throws Exception { + mockMvc + .perform( + get("/api/search/activities") + .param("bases", "PR") + .param("page", "0") + .param("size", "5") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page.number").exists()) + .andExpect(jsonPath("$.page.size").exists()) + .andExpect(jsonPath("$.page.totalElements").exists()) + .andExpect(jsonPath("$.page.totalPages").exists()); + } + + @Test + @DisplayName("GET /api/search/activities without any filters should return error") + void getActivities_withoutAnyFilters_shouldReturnError() throws Exception { + mockMvc + .perform(get("/api/search/activities").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } +} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/service/AbstractActivityServiceIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/service/AbstractActivityServiceIntegrationTest.java new file mode 100644 index 000000000..e8a735565 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/service/AbstractActivityServiceIntegrationTest.java @@ -0,0 +1,196 @@ +package ca.bc.gov.restapi.results.common.service; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchFiltersDto; +import ca.bc.gov.restapi.results.common.dto.activity.ActivitySearchResponseDto; +import ca.bc.gov.restapi.results.extensions.AbstractTestContainerIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WiremockLogNotifier; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +/** + * Abstract integration test contract for ActivityService implementations (Postgres and Oracle). + * Defines common test scenarios for activity search functionality. + */ +@DisplayName("Integrated Test | Activity Service | Contract") +@WithMockJwt(value = "ttester") +public abstract class AbstractActivityServiceIntegrationTest + extends AbstractTestContainerIntegrationTest { + + @RegisterExtension + static WireMockExtension clientApiStub = + WireMockExtension.newInstance() + .options( + wireMockConfig() + .port(10000) + .notifier(new WiremockLogNotifier()) + .asynchronousResponseEnabled(true) + .stubRequestLoggingDisabled(false)) + .configureStaticDsl(true) + .build(); + + @Autowired protected ActivityService activityService; + + @Test + @DisplayName("Activity search with default filters should succeed") + void activitySearch_withDefaultFilters_shouldSucceed() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + null, null, null, null, null, null, null, null, null, null, null, null, null); + Pageable pageable = PageRequest.of(0, 10); + + Page result = activityService.activitySearch(filters, pageable); + + Assertions.assertNotNull(result, "Result should not be null"); + Assertions.assertTrue(result.getNumberOfElements() >= 0, "Result should have content"); + } + + @Test + @DisplayName("Activity search with file ID filter should succeed") + void activitySearch_withFileIdFilter_shouldSucceed() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + null, null, null, null, null, null, null, null, "TFL47", null, null, null, null); + Pageable pageable = PageRequest.of(0, 10); + + Page result = activityService.activitySearch(filters, pageable); + + Assertions.assertNotNull(result, "Result should not be null"); + // Verify results are for the specified file + if (result.hasContent()) { + result + .getContent() + .forEach( + activity -> + Assertions.assertEquals( + "TFL47", activity.fileId(), "File ID should match filter")); + } + } + + @Test + @DisplayName("Activity search with base code filter should succeed") + void activitySearch_withBaseCodeFilter_shouldSucceed() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + List.of("PR"), null, null, null, null, null, null, null, null, null, null, null, null); + Pageable pageable = PageRequest.of(0, 10); + + Page result = activityService.activitySearch(filters, pageable); + + Assertions.assertNotNull(result, "Result should not be null"); + // If there are results, verify they have the correct base code + if (result.hasContent()) { + result + .getContent() + .forEach( + activity -> + Assertions.assertEquals( + "PR", activity.base().code(), "Base code should match filter")); + } + } + + @Test + @DisplayName("Activity search with multiple filters should succeed") + void activitySearch_withMultipleFilters_shouldSucceed() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + List.of("PR", "SP"), + null, + null, + null, + null, + null, + null, + null, + "TFL47", + null, + null, + null, + null); + Pageable pageable = PageRequest.of(0, 10); + + Page result = activityService.activitySearch(filters, pageable); + + Assertions.assertNotNull(result, "Result should not be null"); + } + + @Test + @DisplayName("Activity search with pagination should succeed") + void activitySearch_withPagination_shouldSucceed() { + ActivitySearchFiltersDto filters = new ActivitySearchFiltersDto(); + Pageable pageableFirst = PageRequest.of(0, 5); + Pageable pageableSecond = PageRequest.of(1, 5); + + Page resultFirst = + activityService.activitySearch(filters, pageableFirst); + Page resultSecond = + activityService.activitySearch(filters, pageableSecond); + + Assertions.assertNotNull(resultFirst, "First page result should not be null"); + Assertions.assertNotNull(resultSecond, "Second page result should not be null"); + Assertions.assertEquals(0, resultFirst.getNumber(), "First page number should be 0"); + Assertions.assertEquals(1, resultSecond.getNumber(), "Second page number should be 1"); + } + + @Test + @DisplayName("Activity search response DTOs should have all required fields populated") + void activitySearch_responseDto_shouldHaveAllRequiredFields() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + null, null, null, null, null, null, null, null, "TFL47", null, null, null, null); + Pageable pageable = PageRequest.of(0, 10); + + Page result = activityService.activitySearch(filters, pageable); + + if (result.hasContent()) { + ActivitySearchResponseDto dto = result.getContent().get(0); + Assertions.assertNotNull(dto.openingId(), "Opening ID should not be null"); + Assertions.assertNotNull(dto.activityId(), "Activity ID should not be null"); + Assertions.assertNotNull(dto.base(), "Base should not be null"); + Assertions.assertNotNull(dto.fileId(), "File ID should not be null"); + } + } + + @Test + @DisplayName("Activity search with invalid file ID should return empty results") + void activitySearch_withInvalidFileId_shouldReturnEmpty() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + null, null, null, null, null, null, null, null, "INVALID_FILE", null, null, null, null); + Pageable pageable = PageRequest.of(0, 10); + + Page result = activityService.activitySearch(filters, pageable); + + Assertions.assertNotNull(result, "Result should not be null"); + Assertions.assertFalse(result.hasContent(), "Result should be empty for invalid file ID"); + } + + @Test + @DisplayName("Activity search should not return duplicate entries") + void activitySearch_shouldNotReturnDuplicates() { + ActivitySearchFiltersDto filters = + new ActivitySearchFiltersDto( + null, null, null, null, null, null, null, null, null, null, null, null, null); + Pageable pageable = PageRequest.of(0, 50); + + Page result = activityService.activitySearch(filters, pageable); + + if (result.hasContent()) { + List activityIds = + result.getContent().stream().map(ActivitySearchResponseDto::activityId).toList(); + long uniqueCount = activityIds.stream().distinct().count(); + Assertions.assertEquals( + activityIds.size(), uniqueCount, "Activity search should not return duplicate entries"); + } + } +} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/common/util/DateUtilTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/common/util/DateUtilTest.java new file mode 100644 index 000000000..bd566aff9 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/common/util/DateUtilTest.java @@ -0,0 +1,97 @@ +package ca.bc.gov.restapi.results.common.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@DisplayName("Unit Test | DateUtil") +class DateUtilTest { + + @Test + @DisplayName("validateDateRange | valid dates (end after start) | should pass") + void validateDateRange_validDates_shouldPass() { + assertDoesNotThrow(() -> DateUtil.validateDateRange("2026-01-01", "2026-12-31")); + } + + @Test + @DisplayName("validateDateRange | equal dates | should pass") + void validateDateRange_equalDates_shouldPass() { + assertDoesNotThrow(() -> DateUtil.validateDateRange("2026-06-15", "2026-06-15")); + } + + @Test + @DisplayName("validateDateRange | end before start | should throw exception") + void validateDateRange_endBeforeStart_shouldThrowException() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> DateUtil.validateDateRange("2026-12-31", "2026-01-01")); + assertEquals("End date must be the same or after start date", ex.getReason()); + } + + @Test + @DisplayName("validateDateRange | start is null | should not throw") + void validateDateRange_startNull_shouldNotThrow() { + assertDoesNotThrow(() -> DateUtil.validateDateRange(null, "2026-12-31")); + } + + @Test + @DisplayName("validateDateRange | end is null | should not throw") + void validateDateRange_endNull_shouldNotThrow() { + assertDoesNotThrow(() -> DateUtil.validateDateRange("2026-01-01", null)); + } + + @Test + @DisplayName("validateDateRange | both dates null | should not throw") + void validateDateRange_bothNull_shouldNotThrow() { + assertDoesNotThrow(() -> DateUtil.validateDateRange(null, null)); + } + + @Test + @DisplayName("validateDateRange | invalid date format | should throw exception") + void validateDateRange_invalidDateFormat_shouldThrowException() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> DateUtil.validateDateRange("invalid-date", "2026-12-31")); + assertEquals("Invalid date format. Expected yyyy-MM-dd", ex.getReason()); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + + @Test + @DisplayName("validateDateRange | start invalid format | should throw exception") + void validateDateRange_startInvalidFormat_shouldThrowException() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> DateUtil.validateDateRange("01-01-2026", "2026-12-31")); + assertEquals("Invalid date format. Expected yyyy-MM-dd", ex.getReason()); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + + @Test + @DisplayName("validateDateRange | end invalid format | should throw exception") + void validateDateRange_endInvalidFormat_shouldThrowException() { + ResponseStatusException ex = + assertThrows( + ResponseStatusException.class, + () -> DateUtil.validateDateRange("2026-01-01", "12/31/2026")); + assertEquals("Invalid date format. Expected yyyy-MM-dd", ex.getReason()); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + + @Test + @DisplayName("validateDateRange | leap year date | should pass") + void validateDateRange_leapYearDate_shouldPass() { + assertDoesNotThrow(() -> DateUtil.validateDateRange("2024-02-29", "2024-12-31")); + } + + @Test + @DisplayName("validateDateRange | multi-year range | should pass") + void validateDateRange_multiYearRange_shouldPass() { + assertDoesNotThrow(() -> DateUtil.validateDateRange("2020-01-01", "2030-12-31")); + } +} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/SearchEndpointActivitySearchOracleIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/SearchEndpointActivitySearchOracleIntegrationTest.java new file mode 100644 index 000000000..df4c6ae01 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/endpoint/SearchEndpointActivitySearchOracleIntegrationTest.java @@ -0,0 +1,16 @@ +package ca.bc.gov.restapi.results.oracle.endpoint; + +import ca.bc.gov.restapi.results.common.endpoint.AbstractSearchEndpointActivitySearchIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Integration test for activity search endpoint with Oracle database. Executes all activity search + * endpoint tests against the Oracle database. + */ +@DisplayName("Integrated Test | Activity Search Endpoint | Legacy(Oracle primary)") +@EnabledIfSystemProperty(named = "server.primary-db", matches = "oracle") +@WithMockJwt(value = "ttester") +public class SearchEndpointActivitySearchOracleIntegrationTest + extends AbstractSearchEndpointActivitySearchIntegrationTest {} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/ActivityServiceOracleIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/ActivityServiceOracleIntegrationTest.java new file mode 100644 index 000000000..5dd75f5e1 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/oracle/service/ActivityServiceOracleIntegrationTest.java @@ -0,0 +1,15 @@ +package ca.bc.gov.restapi.results.oracle.service; + +import ca.bc.gov.restapi.results.common.service.AbstractActivityServiceIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Integration test for ActivityService with Oracle database. Executes activity search integration + * tests against the Oracle database. + */ +@DisplayName("Integrated Test | Activity Service | Legacy(Oracle primary)") +@EnabledIfSystemProperty(named = "server.primary-db", matches = "oracle") +@WithMockJwt(value = "ttester") +public class ActivityServiceOracleIntegrationTest extends AbstractActivityServiceIntegrationTest {} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/SearchEndpointActivitySearchPostgresIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/SearchEndpointActivitySearchPostgresIntegrationTest.java new file mode 100644 index 000000000..630a70f08 --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/endpoint/SearchEndpointActivitySearchPostgresIntegrationTest.java @@ -0,0 +1,16 @@ +package ca.bc.gov.restapi.results.postgres.endpoint; + +import ca.bc.gov.restapi.results.common.endpoint.AbstractSearchEndpointActivitySearchIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Integration test for activity search endpoint with PostgreSQL database. Executes all activity + * search endpoint tests against the PostgreSQL database. + */ +@DisplayName("Integrated Test | Activity Search Endpoint | Postgres-only") +@EnabledIfSystemProperty(named = "server.primary-db", matches = "postgres") +@WithMockJwt(value = "ttester") +public class SearchEndpointActivitySearchPostgresIntegrationTest + extends AbstractSearchEndpointActivitySearchIntegrationTest {} diff --git a/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/ActivityServicePostgresIntegrationTest.java b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/ActivityServicePostgresIntegrationTest.java new file mode 100644 index 000000000..d7b9db28e --- /dev/null +++ b/backend/src/test/java/ca/bc/gov/restapi/results/postgres/service/ActivityServicePostgresIntegrationTest.java @@ -0,0 +1,16 @@ +package ca.bc.gov.restapi.results.postgres.service; + +import ca.bc.gov.restapi.results.common.service.AbstractActivityServiceIntegrationTest; +import ca.bc.gov.restapi.results.extensions.WithMockJwt; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +/** + * Integration test for ActivityService with PostgreSQL database. Executes activity search + * integration tests against the PostgreSQL database. + */ +@DisplayName("Integrated Test | Activity Service | Postgres-only") +@EnabledIfSystemProperty(named = "server.primary-db", matches = "postgres") +@WithMockJwt(value = "ttester") +public class ActivityServicePostgresIntegrationTest + extends AbstractActivityServiceIntegrationTest {}