From ca16ca71032b98f24bd5309c9c00fee8b4478303 Mon Sep 17 00:00:00 2001 From: agordon-vivid <159182586+agordon-vivid@users.noreply.github.com> Date: Mon, 9 Dec 2024 11:59:33 -0800 Subject: [PATCH] WFPREV-145 - Added sequence to LB for project number and project status code. (#352) --- db/main-changelog.json | 27 ++ .../sequences/WFPREV.project_number_seq.sql | 3 + .../00/ddl/tables/WFPREV.project_update.sql | 1 + docker-compose.yml | 2 + .../wfprev/controllers/ProjectController.java | 4 + .../assemblers/ProjectResourceAssembler.java | 30 +- .../ProjectStatusCodeResourceAssembler.java | 54 ++++ .../wfprev/data/entities/ProjectEntity.java | 276 +++++++++--------- .../entities/ProjectStatusCodeEntity.java | 55 ++++ .../nrs/wfprev/data/models/ProjectModel.java | 1 + .../data/models/ProjectStatusCodeModel.java | 29 ++ .../ProjectStatusCodeRepository.java | 9 + .../handlers/GlobalExceptionHandler.java | 50 ++++ .../nrs/wfprev/services/ProjectService.java | 32 +- .../gov/nrs/wfprev/ProjectControllerTest.java | 44 +++ ...rojectStatusCodeResourceAssemblerTest.java | 147 ++++++++++ .../handlers/GlobalExceptionHandlerTest.java | 241 +++++++++++++++ .../wfprev/services/ProjectServiceTest.java | 194 +++++++++++- 18 files changed, 1050 insertions(+), 149 deletions(-) create mode 100644 db/scripts/01_00_02/00/ddl/sequences/WFPREV.project_number_seq.sql create mode 100644 db/scripts/01_00_02/00/ddl/tables/WFPREV.project_update.sql create mode 100644 server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssembler.java create mode 100644 server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectStatusCodeEntity.java create mode 100644 server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectStatusCodeModel.java create mode 100644 server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/ProjectStatusCodeRepository.java create mode 100644 server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandler.java create mode 100644 server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssemblerTest.java create mode 100644 server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandlerTest.java diff --git a/db/main-changelog.json b/db/main-changelog.json index 5ff76b8fd..93a382571 100644 --- a/db/main-changelog.json +++ b/db/main-changelog.json @@ -275,6 +275,33 @@ { "sql": "DROP TABLE wfprev.project_type_code" } ] } + }, + { + "changeSet": { + "id": "01_00_02_00", + "author": "agordon", + "tagDatabase": { "tag" : "version_01_00_02_00" }, + "changes": [ + { + "sqlFile": { + "dbms": "postgresql", + "endDelimiter": ";", + "path": "scripts/01_00_02/00/ddl/sequences/WFPREV.project_number_seq.sql" + } + }, + { + "sqlFile": { + "dbms": "postgresql", + "endDelimiter": ";", + "path": "scripts/01_00_02/00/ddl/tables/WFPREV.project_update.sql" + } + } + ], + "rollback": [ + { "sql": "ALTER TABLE wfprev.project ALTER COLUMN project_number DROP DEFAULT" }, + { "sql": "DROP SEQUENCE wfprev.project_number_seq" } + ] + } } ] } \ No newline at end of file diff --git a/db/scripts/01_00_02/00/ddl/sequences/WFPREV.project_number_seq.sql b/db/scripts/01_00_02/00/ddl/sequences/WFPREV.project_number_seq.sql new file mode 100644 index 000000000..87c89aaad --- /dev/null +++ b/db/scripts/01_00_02/00/ddl/sequences/WFPREV.project_number_seq.sql @@ -0,0 +1,3 @@ +CREATE SEQUENCE "wfprev"."project_number_seq" INCREMENT BY 1 START WITH 1000 MAXVALUE 9999999999 MINVALUE 1000 NO CYCLE; + +GRANT USAGE ON SEQUENCE "wfprev"."project_number_seq" TO PROXY_WF1_PREV_REST; diff --git a/db/scripts/01_00_02/00/ddl/tables/WFPREV.project_update.sql b/db/scripts/01_00_02/00/ddl/tables/WFPREV.project_update.sql new file mode 100644 index 000000000..f2e24ad4e --- /dev/null +++ b/db/scripts/01_00_02/00/ddl/tables/WFPREV.project_update.sql @@ -0,0 +1 @@ +ALTER TABLE "wfprev"."project" ALTER COLUMN "project_number" SET DEFAULT nextval('wfprev.project_number_seq'); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index c7fe3760b..ad97010b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,7 @@ services: api: + profiles: + - api build: context: . dockerfile: server/wfprev-api/Dockerfile.graalvm diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ProjectController.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ProjectController.java index 2cc36725a..00fe9dbc8 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ProjectController.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/controllers/ProjectController.java @@ -3,6 +3,7 @@ import java.util.Date; import java.util.UUID; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.hateoas.CollectionModel; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -113,6 +114,9 @@ public ResponseEntity createProject(@RequestBody ProjectModel reso ProjectModel newResource = projectService.createOrUpdateProject(resource); response = newResource == null ? badRequest() : created(newResource); + } catch (DataIntegrityViolationException e) { + response = conflict(); + log.error(" ### Error while creating resource", e); } catch(ServiceException e) { response = internalServerError(); log.error(" ### Error while creating resource", e); diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java index 522bd0c24..310e6f26f 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectResourceAssembler.java @@ -30,12 +30,23 @@ public ProjectEntity toEntity(ProjectModel resource) { ProjectEntity entity = new ProjectEntity(); entity.setProjectGuid(UUID.fromString(resource.getProjectGuid())); - entity.setProjectTypeCode(toProjectTypeCodeEntity(resource.getProjectTypeCode())); - entity.setProjectNumber(resource.getProjectNumber()); + if (resource.getProjectTypeCode() != null) { + entity.setProjectTypeCode(toProjectTypeCodeEntity(resource.getProjectTypeCode())); + } + // Only set project number if it exists (for updates) + if (resource.getProjectNumber() != null) { + entity.setProjectNumber(resource.getProjectNumber()); + } entity.setSiteUnitName(resource.getSiteUnitName()); - entity.setForestAreaCode(toForestAreaCodeEntity(resource.getForestAreaCode())); - entity.setGeneralScopeCode(toGeneralScopeCodeEntity(resource.getGeneralScopeCode())); - entity.setProgramAreaGuid(UUID.fromString(resource.getProgramAreaGuid())); + if (resource.getForestAreaCode() != null) { + entity.setForestAreaCode(toForestAreaCodeEntity(resource.getForestAreaCode())); + } + if (resource.getGeneralScopeCode() != null) { + entity.setGeneralScopeCode(toGeneralScopeCodeEntity(resource.getGeneralScopeCode())); + } + if (resource.getProgramAreaGuid() != null) { + entity.setProgramAreaGuid(UUID.fromString(resource.getProgramAreaGuid())); + } entity.setForestRegionOrgUnitId(resource.getForestRegionOrgUnitId()); entity.setForestDistrictOrgUnitId(resource.getForestDistrictOrgUnitId()); entity.setFireCentreOrgUnitId(resource.getFireCentreOrgUnitId()); @@ -79,8 +90,12 @@ public ProjectModel toModel(ProjectEntity entity) { resource.setProjectTypeCode(toProjectTypeCodeModel(entity.getProjectTypeCode())); resource.setProjectNumber(entity.getProjectNumber()); resource.setSiteUnitName(entity.getSiteUnitName()); - resource.setForestAreaCode(toForestAreaCodeModel(entity.getForestAreaCode())); - resource.setGeneralScopeCode(toGeneralScopeCodeModel(entity.getGeneralScopeCode())); + if (entity.getForestAreaCode() != null) { + resource.setForestAreaCode(toForestAreaCodeModel(entity.getForestAreaCode())); + } + if (entity.getGeneralScopeCode() != null) { + resource.setGeneralScopeCode(toGeneralScopeCodeModel(entity.getGeneralScopeCode())); + } resource.setProgramAreaGuid(entity.getProgramAreaGuid().toString()); resource.setForestRegionOrgUnitId(entity.getForestRegionOrgUnitId()); resource.setForestDistrictOrgUnitId(entity.getForestDistrictOrgUnitId()); @@ -125,6 +140,7 @@ private ProjectTypeCodeModel toProjectTypeCodeModel(ProjectTypeCodeEntity code) } private ProjectTypeCodeEntity toProjectTypeCodeEntity(ProjectTypeCodeModel code) { + if (code == null) return null; ProjectTypeCodeResourceAssembler ra = new ProjectTypeCodeResourceAssembler(); return ra.toEntity(code); } diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssembler.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssembler.java new file mode 100644 index 000000000..14d902a8a --- /dev/null +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssembler.java @@ -0,0 +1,54 @@ +package ca.bc.gov.nrs.wfprev.data.assemblers; + +import ca.bc.gov.nrs.wfprev.controllers.CodesController; +import ca.bc.gov.nrs.wfprev.data.entities.ProjectStatusCodeEntity; +import ca.bc.gov.nrs.wfprev.data.models.ProjectStatusCodeModel; +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport; +import org.springframework.stereotype.Component; + +@Component +public class ProjectStatusCodeResourceAssembler extends RepresentationModelAssemblerSupport { + public ProjectStatusCodeResourceAssembler() { + super(CodesController.class, ProjectStatusCodeModel.class); + } + + @Override + public ProjectStatusCodeModel toModel(ProjectStatusCodeEntity entity) { + ProjectStatusCodeModel model = instantiateModel(entity); + + model.setProjectStatusCode(entity.getProjectStatusCode()); + model.setDescription(entity.getDescription()); + model.setDisplayOrder(entity.getDisplayOrder()); + model.setEffectiveDate(entity.getEffectiveDate()); + model.setExpiryDate(entity.getExpiryDate()); + model.setDisplayOrder(entity.getDisplayOrder()); + model.setCreateDate(entity.getCreateDate()); + model.setCreateUser(entity.getCreateUser()); + model.setUpdateDate(entity.getUpdateDate()); + model.setUpdateUser(entity.getUpdateUser()); + model.setRevisionCount(entity.getRevisionCount()); + + return model; + } + + public ProjectStatusCodeEntity toEntity(ProjectStatusCodeModel model) { + if (model == null) { + return null; + } + + ProjectStatusCodeEntity entity = new ProjectStatusCodeEntity(); + + entity.setProjectStatusCode(model.getProjectStatusCode()); + entity.setDescription(model.getDescription()); + entity.setDisplayOrder(model.getDisplayOrder()); + entity.setEffectiveDate(model.getEffectiveDate()); + entity.setExpiryDate(model.getExpiryDate()); + entity.setCreateDate(model.getCreateDate()); + entity.setCreateUser(model.getCreateUser()); + entity.setUpdateDate(model.getUpdateDate()); + entity.setUpdateUser(model.getUpdateUser()); + entity.setRevisionCount(model.getRevisionCount()); + + return entity; + } +} diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java index 1c5ce4268..868e10df6 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectEntity.java @@ -1,24 +1,9 @@ package ca.bc.gov.nrs.wfprev.data.entities; -import java.io.Serializable; -import java.math.BigDecimal; -import java.util.Date; -import java.util.UUID; - -import org.hibernate.annotations.UuidGenerator; -import org.springframework.data.annotation.CreatedBy; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedBy; -import org.springframework.data.annotation.LastModifiedDate; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; - -import ca.bc.gov.nrs.wfprev.common.converters.BooleanConverter; import ca.bc.gov.nrs.wfprev.common.validators.Latitude; import ca.bc.gov.nrs.wfprev.common.validators.Longitude; -import jakarta.persistence.CascadeType; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.Column; -import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -34,6 +19,16 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; +import java.util.UUID; @Entity @Table(name = "project") @@ -44,126 +39,131 @@ @AllArgsConstructor @NoArgsConstructor public class ProjectEntity implements Serializable { - @Id - @UuidGenerator - @GeneratedValue(strategy = GenerationType.UUID) - @Column(name = "project_guid") - private UUID projectGuid; - - @ManyToOne(fetch = FetchType.EAGER, optional = true) - @JoinColumn(name="project_type_code") - private ProjectTypeCodeEntity projectTypeCode; - @Column(name = "project_number", columnDefinition="Decimal(10)") - @NotNull - private Integer projectNumber; - - @NotNull - @Column(name="site_unit_name", length = 250) - private String siteUnitName; - - @ManyToOne(fetch = FetchType.EAGER, optional = true) - @JoinColumn(name="forest_area_code") - private ForestAreaCodeEntity forestAreaCode; - - @ManyToOne(fetch = FetchType.EAGER, optional = true) - @JoinColumn(name="general_scope_code") - private GeneralScopeCodeEntity generalScopeCode; - - @Column(name = "program_area_guid", columnDefinition = "uuid") - @NotNull - private UUID programAreaGuid; - - @Column(name = "forest_region_org_unit_id", columnDefinition="Decimal(10)") - private Integer forestRegionOrgUnitId; - - @Column(name = "forest_district_org_unit_id", columnDefinition="Decimal(10)") - private Integer forestDistrictOrgUnitId; - - @Column(name = "fire_centre_org_unit_id", columnDefinition="Decimal(10)") - private Integer fireCentreOrgUnitId; - - @Column(name = "bc_parks_region_org_unit_id", columnDefinition="Decimal(10)") - private Integer bcParksRegionOrgUnitId; - - @Column(name = "bcParksSectionOrgUnitId", columnDefinition="Decimal(10)") - private Integer bcParksSectionOrgUnitId; - - @NotNull - @Column(name="project_name", length = 300) - private String projectName; - - @Column(name="project_lead", length = 300) - private String projectLead; - - @Column(name="project_lead_email_address", length = 100) - private String projectLeadEmailAddress; - - @Column(name="project_description", length = 4000) - private String projectDescription; - - @Column(name="closest_community_name", length = 250) - private String closestCommunityName; - - @Column(name = "total_funding_request_amount", precision = 15, scale = 2) - private BigDecimal totalFundingRequestAmount; - - @Column(name = "total_allocated_amount", precision = 15, scale = 2) - private BigDecimal totalAllocatedAmount; - - @Column(name = "total_planned_project_size_ha", columnDefinition="Decimal(15, 4) default '0'") - private BigDecimal totalPlannedProjectSizeHa; - - @Column(name = "total_planned_cost_per_hectare", columnDefinition="Decimal(15, 2) default '0'") - private BigDecimal totalPlannedCostPerHectare; - - @NotNull - @Column(name = "total_actual_amount", columnDefinition="Decimal(15, 2) default '0'") - private BigDecimal totalActualAmount; - - @Column(name = "total_actual_project_size_ha", columnDefinition="Decimal(15, 4) default '0'") - private BigDecimal totalActualProjectSizeHa; - - @Column(name = "total_actual_cost_per_hectare_amount", columnDefinition="Decimal(15, 2) default '0'") - private BigDecimal totalActualCostPerHectareAmount; - - @NotNull - @Column(name = "is_multi_fiscal_year_proj_ind") - @Builder.Default - private Boolean isMultiFiscalYearProj = false; - - @Column(name = "latitude", precision = 9, scale = 6) - @Latitude - private BigDecimal latitude; - - @Column(name = "longitude", precision = 9, scale = 6) - @Longitude - private BigDecimal longitude; - - @Column(name="last_progress_update_timestamp") - private Date lastProgressUpdateTimestamp; - - @Column(name = "revision_count", columnDefinition="Decimal(10) default '0'") - @NotNull - @Version - private Integer revisionCount; - - @CreatedBy - @NotNull - @Column(name="create_user", length = 64) - private String createUser; - - @CreatedDate - @NotNull - @Column(name="create_date") - private Date createDate; - - @LastModifiedBy - @NotNull - @Column(name="update_user", length = 64) - private String updateUser; - - @LastModifiedDate - @NotNull - @Column(name="update_date") - private Date updateDate; + @Id + @UuidGenerator + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "project_guid") + private UUID projectGuid; + + @ManyToOne(fetch = FetchType.EAGER, optional = true) + @JoinColumn(name = "project_type_code") + private ProjectTypeCodeEntity projectTypeCode; + + @Column(name = "project_number", columnDefinition="Decimal(10)", insertable = false, updatable = true) + private Integer projectNumber; + + @NotNull + @Column(name = "site_unit_name", length = 250) + private String siteUnitName; + + @ManyToOne(fetch = FetchType.EAGER, optional = true) + @JoinColumn(name = "forest_area_code") + private ForestAreaCodeEntity forestAreaCode; + + @ManyToOne(fetch = FetchType.EAGER, optional = true) + @JoinColumn(name = "general_scope_code") + private GeneralScopeCodeEntity generalScopeCode; + + @Column(name = "program_area_guid", columnDefinition = "uuid") + @NotNull + private UUID programAreaGuid; + + @Column(name = "forest_region_org_unit_id", columnDefinition = "Decimal(10)") + @NotNull + private Integer forestRegionOrgUnitId; + + @Column(name = "forest_district_org_unit_id", columnDefinition = "Decimal(10)") + private Integer forestDistrictOrgUnitId; + + @Column(name = "fire_centre_org_unit_id", columnDefinition = "Decimal(10)") + private Integer fireCentreOrgUnitId; + + @Column(name = "bc_parks_region_org_unit_id", columnDefinition = "Decimal(10)") + private Integer bcParksRegionOrgUnitId; + + @Column(name = "bcParksSectionOrgUnitId", columnDefinition = "Decimal(10)") + private Integer bcParksSectionOrgUnitId; + + @NotNull + @Column(name = "project_name", length = 300) + private String projectName; + + @Column(name = "project_lead", length = 300) + private String projectLead; + + @Column(name = "project_lead_email_address", length = 100) + private String projectLeadEmailAddress; + + @Column(name = "project_description", length = 4000) + private String projectDescription; + + @Column(name = "closest_community_name", length = 250) + private String closestCommunityName; + + @Column(name = "total_funding_request_amount", precision = 15, scale = 2) + private BigDecimal totalFundingRequestAmount; + + @Column(name = "total_allocated_amount", precision = 15, scale = 2) + private BigDecimal totalAllocatedAmount; + + @Column(name = "total_planned_project_size_ha", columnDefinition = "Decimal(15, 4) default '0'") + private BigDecimal totalPlannedProjectSizeHa; + + @Column(name = "total_planned_cost_per_hectare", columnDefinition = "Decimal(15, 2) default '0'") + private BigDecimal totalPlannedCostPerHectare; + + @NotNull + @Column(name = "total_actual_amount", columnDefinition = "Decimal(15, 2) default '0'") + private BigDecimal totalActualAmount; + + @Column(name = "total_actual_project_size_ha", columnDefinition = "Decimal(15, 4) default '0'") + private BigDecimal totalActualProjectSizeHa; + + @Column(name = "total_actual_cost_per_hectare_amount", columnDefinition = "Decimal(15, 2) default '0'") + private BigDecimal totalActualCostPerHectareAmount; + + @NotNull + @Column(name = "is_multi_fiscal_year_proj_ind") + @Builder.Default + private Boolean isMultiFiscalYearProj = false; + + @Column(name = "latitude", precision = 9, scale = 6) + @Latitude + private BigDecimal latitude; + + @Column(name = "longitude", precision = 9, scale = 6) + @Longitude + private BigDecimal longitude; + + @Column(name = "last_progress_update_timestamp") + private Date lastProgressUpdateTimestamp; + + @Column(name = "revision_count", columnDefinition = "Decimal(10) default '0'") + @NotNull + @Version + private Integer revisionCount; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name="project_status_code") + private ProjectStatusCodeEntity projectStatusCode; + + @CreatedBy + @NotNull + @Column(name = "create_user", length = 64) + private String createUser; + + @CreatedDate + @NotNull + @Column(name = "create_date") + private Date createDate; + + @LastModifiedBy + @NotNull + @Column(name = "update_user", length = 64) + private String updateUser; + + @LastModifiedDate + @NotNull + @Column(name = "update_date") + private Date updateDate; } diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectStatusCodeEntity.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectStatusCodeEntity.java new file mode 100644 index 000000000..0dad46a9a --- /dev/null +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/entities/ProjectStatusCodeEntity.java @@ -0,0 +1,55 @@ +package ca.bc.gov.nrs.wfprev.data.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Entity +@Table(name = "project_status_code") +@Data +@EqualsAndHashCode(callSuper = false) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ProjectStatusCodeEntity { + @Id + @Column(name = "project_status_code") + private String projectStatusCode; + + @Column(name = "description") + private String description; + + @Column(name = "display_order", columnDefinition="Decimal(3)") + private Integer displayOrder; + + @Column(name = "effective_date") + private Date effectiveDate; + + @Column(name = "expiry_date") + private Date expiryDate; + + @Version + @Column(name = "revision_count", columnDefinition="Decimal(10)") + private Integer revisionCount; + + @Column(name = "create_user") + private String createUser; + + @Column(name = "create_date") + private Date createDate; + + @Column(name = "update_user") + private String updateUser; + + @Column(name = "update_date") + private Date updateDate; +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectModel.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectModel.java index 58733e03a..b88393eaf 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectModel.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectModel.java @@ -31,6 +31,7 @@ public class ProjectModel extends CommonModel { private String siteUnitName; private ForestAreaCodeModel forestAreaCode; private GeneralScopeCodeModel generalScopeCode; + private ProjectStatusCodeModel projectStatusCode; private String programAreaGuid; private Integer forestRegionOrgUnitId; private Integer forestDistrictOrgUnitId; diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectStatusCodeModel.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectStatusCodeModel.java new file mode 100644 index 000000000..c8558aece --- /dev/null +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/models/ProjectStatusCodeModel.java @@ -0,0 +1,29 @@ +package ca.bc.gov.nrs.wfprev.data.models; + +import ca.bc.gov.nrs.wfprev.common.entities.CommonModel; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonRootName; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.springframework.hateoas.server.core.Relation; + +import java.util.Date; + +@Data +@EqualsAndHashCode(callSuper = false) +@JsonRootName(value = "project_status_code") +@Relation(collectionRelation = "project_status_code") +@JsonInclude(JsonInclude.Include.NON_NULL) +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ProjectStatusCodeModel extends CommonModel { + private String projectStatusCode; + private String description; + private Integer displayOrder; + private Date effectiveDate; + private Date expiryDate; +} diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/ProjectStatusCodeRepository.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/ProjectStatusCodeRepository.java new file mode 100644 index 000000000..866d3f268 --- /dev/null +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/data/repositories/ProjectStatusCodeRepository.java @@ -0,0 +1,9 @@ +package ca.bc.gov.nrs.wfprev.data.repositories; + +import ca.bc.gov.nrs.wfprev.data.entities.ProjectStatusCodeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.rest.core.annotation.RepositoryRestResource; + +@RepositoryRestResource(exported = false) +public interface ProjectStatusCodeRepository extends JpaRepository { +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandler.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandler.java new file mode 100644 index 000000000..e79845863 --- /dev/null +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package ca.bc.gov.nrs.wfprev.handlers; + +import jakarta.validation.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import java.util.HashMap; +import java.util.Map; + +@ControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler({ConstraintViolationException.class, DataIntegrityViolationException.class}) + public ResponseEntity handleValidationExceptions(Exception ex) { + Map errors = new HashMap<>(); + + if (ex instanceof ConstraintViolationException) { + ConstraintViolationException cve = (ConstraintViolationException) ex; + cve.getConstraintViolations().forEach(violation -> { + String fieldName = violation.getPropertyPath() != null ? + violation.getPropertyPath().toString() : + "unknown_field"; + String errorMessage = violation.getMessage() != null ? + violation.getMessage() : + "unknown error"; + errors.put(fieldName, errorMessage); + }); + } else { + // DataIntegrityViolationException + errors.put("error", "Data integrity violation: " + ex.getMessage()); + } + + return ResponseEntity + .status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(errors); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadableException ex) { + Map errors = new HashMap<>(); + errors.put("error", "Invalid JSON format"); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errors); + } +} \ No newline at end of file diff --git a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java index 641516ce8..340b312c2 100644 --- a/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java +++ b/server/wfprev-api/src/main/java/ca/bc/gov/nrs/wfprev/services/ProjectService.java @@ -5,14 +5,20 @@ import java.util.Optional; import java.util.UUID; +import ca.bc.gov.nrs.wfprev.data.assemblers.ProjectStatusCodeResourceAssembler; import ca.bc.gov.nrs.wfprev.data.entities.ForestAreaCodeEntity; import ca.bc.gov.nrs.wfprev.data.entities.GeneralScopeCodeEntity; +import ca.bc.gov.nrs.wfprev.data.entities.ProjectStatusCodeEntity; import ca.bc.gov.nrs.wfprev.data.entities.ProjectTypeCodeEntity; +import ca.bc.gov.nrs.wfprev.data.models.ProjectStatusCodeModel; import ca.bc.gov.nrs.wfprev.data.repositories.ForestAreaCodeRepository; import ca.bc.gov.nrs.wfprev.data.repositories.GeneralScopeCodeRepository; +import ca.bc.gov.nrs.wfprev.data.repositories.ProjectStatusCodeRepository; import ca.bc.gov.nrs.wfprev.data.repositories.ProjectTypeCodeRepository; import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.hateoas.CollectionModel; import org.springframework.stereotype.Component; @@ -28,6 +34,7 @@ @Slf4j @Component public class ProjectService implements CommonService { + private final ProjectStatusCodeResourceAssembler projectStatusCodeAssembler; private ProjectRepository projectRepository; private ProjectResourceAssembler projectResourceAssembler; @@ -40,9 +47,13 @@ public class ProjectService implements CommonService { @Autowired private GeneralScopeCodeRepository generalScopeCodeRepository; - public ProjectService(ProjectRepository projectRepository, ProjectResourceAssembler projectResourceAssembler) { + @Autowired + private ProjectStatusCodeRepository projectStatusCodeRepository; + + public ProjectService(ProjectRepository projectRepository, ProjectResourceAssembler projectResourceAssembler, ProjectStatusCodeResourceAssembler projectStatusCodeAssembler) { this.projectRepository = projectRepository; this.projectResourceAssembler = projectResourceAssembler; + this.projectStatusCodeAssembler = projectStatusCodeAssembler; } public CollectionModel getAllProjects() throws ServiceException { @@ -116,11 +127,30 @@ public ProjectModel createOrUpdateProject(ProjectModel resource) throws ServiceE } } + if (resource.getProjectStatusCode() == null) { + ProjectStatusCodeEntity activeStatus = projectStatusCodeRepository.findById("ACTIVE") + .orElseThrow(() -> new EntityNotFoundException("Project Status Code 'ACTIVE' not found")); + entity.setProjectStatusCode(activeStatus); + } else { + String projectStatusCode = resource.getProjectStatusCode().getProjectStatusCode(); + if (projectStatusCode != null) { + ProjectStatusCodeEntity projectStatusCodeEntity = projectStatusCodeRepository + .findById(projectStatusCode) + .orElseThrow(() -> new EntityNotFoundException( + "Project Status Code not found: " + projectStatusCode)); + entity.setProjectStatusCode(projectStatusCodeEntity); + } + } + ProjectEntity savedEntity = projectRepository.saveAndFlush(entity); return projectResourceAssembler.toModel(savedEntity); } catch (EntityNotFoundException e) { throw new ServiceException("Invalid reference data: " + e.getMessage(), e); + } catch (DataIntegrityViolationException e) { + throw new DataIntegrityViolationException(e.getMessage(), e); + } catch (ConstraintViolationException e) { + throw e; } catch (Exception e) { log.error("Error creating/updating project", e); // Add logging throw new ServiceException(e.getLocalizedMessage(), e); diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java index b6ba67f54..4effaa7ac 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/ProjectControllerTest.java @@ -19,6 +19,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.hateoas.CollectionModel; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; @@ -239,6 +240,49 @@ void testCreateProject_ServiceException() throws Exception { assertEquals(500, result.andReturn().getResponse().getStatus()); } + @Test + @WithMockUser + void testCreateProject_Conflict() throws Exception { + // Given + ProjectModel project = new ProjectModel(); + project.setProjectTypeCode(new ProjectTypeCodeModel()); + project.setProjectNumber(1); + project.setSiteUnitName("Test"); + project.setProgramAreaGuid(UUID.randomUUID().toString()); + project.setProjectName("Test"); + project.setIsMultiFiscalYearProj(false); + project.setLatitude(new BigDecimal(40.99)); + project.setLongitude(new BigDecimal(-115.23)); + project.setLastProgressUpdateTimestamp(new Date()); + String projectGuid = UUID.randomUUID().toString(); + project.setProjectGuid(projectGuid); + + when(projectService.createOrUpdateProject(any(ProjectModel.class))).thenReturn(project); + + String json = gson.toJson(project); + + mockMvc.perform(post("/projects") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept("application/json") + .header("Authorization", "Bearer admin-token")) + .andExpect(status().isCreated()); + + when(projectService.createOrUpdateProject(any(ProjectModel.class))).thenThrow(new DataIntegrityViolationException("Error creating project")); + + // When + ResultActions result = mockMvc.perform(post("/projects") + .contentType(MediaType.APPLICATION_JSON) + .content(json) + .accept("application/json") + .header("Authorization", "Bearer admin-token")) + .andExpect(status().isConflict()); + + // Then + verify(projectService, times(2)).createOrUpdateProject(any(ProjectModel.class)); + assertEquals(409, result.andReturn().getResponse().getStatus()); + } + @Test @WithMockUser void testUpdateProject_BadRequest() throws Exception { diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssemblerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssemblerTest.java new file mode 100644 index 000000000..d680b9de1 --- /dev/null +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/data/assemblers/ProjectStatusCodeResourceAssemblerTest.java @@ -0,0 +1,147 @@ +package ca.bc.gov.nrs.wfprev.data.assemblers; + +import ca.bc.gov.nrs.wfprev.data.entities.ProjectStatusCodeEntity; +import ca.bc.gov.nrs.wfprev.data.models.ProjectStatusCodeModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; + +class ProjectStatusCodeResourceAssemblerTest { + + private ProjectStatusCodeResourceAssembler assembler; + private static final String PROJECT_STATUS_CODE = "ACTIVE"; + private static final String DESCRIPTION = "Active status"; + private static final Integer DISPLAY_ORDER = 1; + private static final Date EFFECTIVE_DATE = new Date(); + private static final Date EXPIRY_DATE = new Date(); + private static final String CREATE_USER = "SYSTEM"; + private static final Date CREATE_DATE = new Date(); + private static final String UPDATE_USER = "SYSTEM"; + private static final Date UPDATE_DATE = new Date(); + private static final Integer REVISION_COUNT = 0; + + @BeforeEach + void setUp() { + assembler = new ProjectStatusCodeResourceAssembler(); + } + + @Test + void toModel_ShouldConvertAllFields() { + // Given + ProjectStatusCodeEntity entity = new ProjectStatusCodeEntity(); + entity.setProjectStatusCode(PROJECT_STATUS_CODE); + entity.setDescription(DESCRIPTION); + entity.setDisplayOrder(DISPLAY_ORDER); + entity.setEffectiveDate(EFFECTIVE_DATE); + entity.setExpiryDate(EXPIRY_DATE); + entity.setCreateDate(CREATE_DATE); + entity.setCreateUser(CREATE_USER); + entity.setUpdateDate(UPDATE_DATE); + entity.setUpdateUser(UPDATE_USER); + entity.setRevisionCount(REVISION_COUNT); + + // When + ProjectStatusCodeModel model = assembler.toModel(entity); + + // Then + assertNotNull(model); + assertEquals(PROJECT_STATUS_CODE, model.getProjectStatusCode()); + assertEquals(DESCRIPTION, model.getDescription()); + assertEquals(DISPLAY_ORDER, model.getDisplayOrder()); + assertEquals(EFFECTIVE_DATE, model.getEffectiveDate()); + assertEquals(EXPIRY_DATE, model.getExpiryDate()); + assertEquals(CREATE_DATE, model.getCreateDate()); + assertEquals(CREATE_USER, model.getCreateUser()); + assertEquals(UPDATE_DATE, model.getUpdateDate()); + assertEquals(UPDATE_USER, model.getUpdateUser()); + assertEquals(REVISION_COUNT, model.getRevisionCount()); + } + + @Test + void toEntity_ShouldConvertAllFields() { + // Given + ProjectStatusCodeModel model = new ProjectStatusCodeModel(); + model.setProjectStatusCode(PROJECT_STATUS_CODE); + model.setDescription(DESCRIPTION); + model.setDisplayOrder(DISPLAY_ORDER); + model.setEffectiveDate(EFFECTIVE_DATE); + model.setExpiryDate(EXPIRY_DATE); + model.setCreateDate(CREATE_DATE); + model.setCreateUser(CREATE_USER); + model.setUpdateDate(UPDATE_DATE); + model.setUpdateUser(UPDATE_USER); + model.setRevisionCount(REVISION_COUNT); + + // When + ProjectStatusCodeEntity entity = assembler.toEntity(model); + + // Then + assertNotNull(entity); + assertEquals(PROJECT_STATUS_CODE, entity.getProjectStatusCode()); + assertEquals(DESCRIPTION, entity.getDescription()); + assertEquals(DISPLAY_ORDER, entity.getDisplayOrder()); + assertEquals(EFFECTIVE_DATE, entity.getEffectiveDate()); + assertEquals(EXPIRY_DATE, entity.getExpiryDate()); + assertEquals(CREATE_DATE, entity.getCreateDate()); + assertEquals(CREATE_USER, entity.getCreateUser()); + assertEquals(UPDATE_DATE, entity.getUpdateDate()); + assertEquals(UPDATE_USER, entity.getUpdateUser()); + assertEquals(REVISION_COUNT, entity.getRevisionCount()); + } + + @Test + void toEntity_ShouldReturnNull_WhenModelIsNull() { + // When + ProjectStatusCodeEntity entity = assembler.toEntity(null); + + // Then + assertNull(entity); + } + + @Test + void toEntity_ShouldHandleNullFields() { + // Given + ProjectStatusCodeModel model = new ProjectStatusCodeModel(); + + // When + ProjectStatusCodeEntity entity = assembler.toEntity(model); + + // Then + assertNotNull(entity); + assertNull(entity.getProjectStatusCode()); + assertNull(entity.getDescription()); + assertNull(entity.getDisplayOrder()); + assertNull(entity.getEffectiveDate()); + assertNull(entity.getExpiryDate()); + assertNull(entity.getCreateDate()); + assertNull(entity.getCreateUser()); + assertNull(entity.getUpdateDate()); + assertNull(entity.getUpdateUser()); + assertNull(entity.getRevisionCount()); + } + + @Test + void toModel_ShouldHandleNullFields() { + // Given + ProjectStatusCodeEntity entity = new ProjectStatusCodeEntity(); + + // When + ProjectStatusCodeModel model = assembler.toModel(entity); + + // Then + assertNotNull(model); + assertNull(model.getProjectStatusCode()); + assertNull(model.getDescription()); + assertNull(model.getDisplayOrder()); + assertNull(model.getEffectiveDate()); + assertNull(model.getExpiryDate()); + assertNull(model.getCreateDate()); + assertNull(model.getCreateUser()); + assertNull(model.getUpdateDate()); + assertNull(model.getUpdateUser()); + assertNull(model.getRevisionCount()); + } +} \ No newline at end of file diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandlerTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandlerTest.java new file mode 100644 index 000000000..7171b8342 --- /dev/null +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/handlers/GlobalExceptionHandlerTest.java @@ -0,0 +1,241 @@ +package ca.bc.gov.nrs.wfprev.handlers; + +import com.fasterxml.jackson.core.JsonParseException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import org.hibernate.validator.internal.engine.path.PathImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) +public class GlobalExceptionHandlerTest { + + @InjectMocks + private GlobalExceptionHandler handler; + + @Test + public void testHandleConstraintViolation_SingleError() { + // Given + Set> violations = new HashSet<>(); + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getPropertyPath()).thenReturn(PathImpl.createPathFromString("siteUnitName")); + when(violation.getMessage()).thenReturn("must not be null"); + violations.add(violation); + + ConstraintViolationException ex = new ConstraintViolationException("Test violation", violations); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("must not be null", errors.get("siteUnitName")); + } + + @Test + public void testHandleConstraintViolation_MultipleErrors() { + // Given + Set> violations = new HashSet<>(); + + ConstraintViolation violation1 = mock(ConstraintViolation.class); + when(violation1.getPropertyPath()).thenReturn(PathImpl.createPathFromString("siteUnitName")); + when(violation1.getMessage()).thenReturn("must not be null"); + violations.add(violation1); + + ConstraintViolation violation2 = mock(ConstraintViolation.class); + when(violation2.getPropertyPath()).thenReturn(PathImpl.createPathFromString("projectLead")); + when(violation2.getMessage()).thenReturn("must not be empty"); + violations.add(violation2); + + ConstraintViolationException ex = new ConstraintViolationException("Test violations", violations); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(2, errors.size()); + assertEquals("must not be null", errors.get("siteUnitName")); + assertEquals("must not be empty", errors.get("projectLead")); + } + + @Test + public void testHandleConstraintViolation_NoViolations() { + // Given + Set> violations = new HashSet<>(); + ConstraintViolationException ex = new ConstraintViolationException("Test with no violations", violations); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertTrue(errors.isEmpty()); + } + + @Test + public void testHandleDataIntegrityViolation() { + // Given + String errorMessage = "null value in column \"forest_region_org_unit_id\" of relation \"project\" violates not-null constraint"; + DataIntegrityViolationException ex = new DataIntegrityViolationException(errorMessage); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("Data integrity violation: " + errorMessage, errors.get("error")); + } + + @Test + public void testHandleDataIntegrityViolation_NullMessage() { + // Given + DataIntegrityViolationException ex = new DataIntegrityViolationException(null); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("Data integrity violation: null", errors.get("error")); + } + + @Test + public void testHandleConstraintViolation_NullPath() { + // Given + Set> violations = new HashSet<>(); + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getPropertyPath()).thenReturn(null); + when(violation.getMessage()).thenReturn("must not be null"); + violations.add(violation); + + ConstraintViolationException ex = new ConstraintViolationException("Test violation", violations); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("must not be null", errors.get("unknown_field")); + } + + @Test + public void testHandleConstraintViolation_NullMessage() { + // Given + Set> violations = new HashSet<>(); + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getPropertyPath()).thenReturn(PathImpl.createPathFromString("siteUnitName")); + when(violation.getMessage()).thenReturn(null); + violations.add(violation); + + ConstraintViolationException ex = new ConstraintViolationException("Test violation", violations); + + // When + ResponseEntity response = handler.handleValidationExceptions(ex); + + // Then + assertEquals(HttpStatus.UNPROCESSABLE_ENTITY, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("unknown error", errors.get("siteUnitName")); // Changed from "null" to "unknown error" + } + @Test + public void testHandleHttpMessageNotReadable() { + // Given + HttpMessageNotReadableException ex = new HttpMessageNotReadableException("Test message"); + + // When + ResponseEntity response = handler.handleHttpMessageNotReadable(ex); + + // Then + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("Invalid JSON format", errors.get("error")); + } + + @Test + public void testHandleHttpMessageNotReadable_NullMessage() { + // Given + HttpMessageNotReadableException ex = new HttpMessageNotReadableException(null); + + // When + ResponseEntity response = handler.handleHttpMessageNotReadable(ex); + + // Then + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("Invalid JSON format", errors.get("error")); + } + + @Test + public void testHandleHttpMessageNotReadable_JsonParseError() { + // Given + JsonParseException jsonEx = new JsonParseException(null, "Invalid JSON"); + HttpMessageNotReadableException ex = new HttpMessageNotReadableException("Test message", jsonEx); + + // When + ResponseEntity response = handler.handleHttpMessageNotReadable(ex); + + // Then + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody() instanceof Map); + + @SuppressWarnings("unchecked") + Map errors = (Map) response.getBody(); + assertEquals(1, errors.size()); + assertEquals("Invalid JSON format", errors.get("error")); + } +} \ No newline at end of file diff --git a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java index f9229f9e6..f9d7e9df4 100644 --- a/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java +++ b/server/wfprev-api/src/test/java/ca/bc/gov/nrs/wfprev/services/ProjectServiceTest.java @@ -2,13 +2,19 @@ import ca.bc.gov.nrs.wfone.common.service.api.ServiceException; import ca.bc.gov.nrs.wfprev.data.assemblers.ProjectResourceAssembler; +import ca.bc.gov.nrs.wfprev.data.assemblers.ProjectStatusCodeResourceAssembler; import ca.bc.gov.nrs.wfprev.data.entities.*; import ca.bc.gov.nrs.wfprev.data.models.*; import ca.bc.gov.nrs.wfprev.data.repositories.*; import jakarta.persistence.EntityNotFoundException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validation; +import org.hibernate.validator.internal.engine.path.PathImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.hateoas.CollectionModel; import java.math.BigDecimal; @@ -25,19 +31,24 @@ public class ProjectServiceTest { private ForestAreaCodeRepository forestAreaCodeRepository; private ProjectTypeCodeRepository projectTypeCodeRepository; private GeneralScopeCodeRepository generalScopeCodeRepository; + private ProjectStatusCodeResourceAssembler projectStatusCodeAssembler; + private ProjectStatusCodeRepository projectStatusCodeRepository; @BeforeEach public void setup() { projectRepository = mock(ProjectRepository.class); projectResourceAssembler = mock(ProjectResourceAssembler.class); + projectStatusCodeAssembler = mock(ProjectStatusCodeResourceAssembler.class); forestAreaCodeRepository = mock(ForestAreaCodeRepository.class); projectTypeCodeRepository = mock(ProjectTypeCodeRepository.class); generalScopeCodeRepository = mock(GeneralScopeCodeRepository.class); + projectStatusCodeRepository = mock(ProjectStatusCodeRepository.class); - projectService = new ProjectService(projectRepository, projectResourceAssembler); + projectService = new ProjectService(projectRepository, projectResourceAssembler, projectStatusCodeAssembler); setField(projectService, "forestAreaCodeRepository", forestAreaCodeRepository); setField(projectService, "projectTypeCodeRepository", projectTypeCodeRepository); setField(projectService, "generalScopeCodeRepository", generalScopeCodeRepository); + setField(projectService, "projectStatusCodeRepository", projectStatusCodeRepository); } @Test @@ -150,6 +161,126 @@ public void test_get_project_by_id_with_exception() { assertTrue(exception.getMessage().contains("Error fetching project")); } + @Test + public void testCreate_DataIntegrityViolationException() { + // Given I am creating a new project + ProjectModel inputModel = ProjectModel.builder() + .projectName("Test Project") + .siteUnitName("Test Site") + .projectLead("Test Lead") + .projectLeadEmailAddress("test@example.com") + .isMultiFiscalYearProj(false) + .totalActualProjectSizeHa(BigDecimal.valueOf(100)) + .build(); + + // When I call the createOrUpdateProject method ith a duplicate project number combo causing a DataIntegrityViolationException + when(projectResourceAssembler.toEntity(any())).thenThrow(new DataIntegrityViolationException("Error saving project")); + + //Then I should throw a DataIntegrityViolationException + assertThrows( + DataIntegrityViolationException.class, + () -> projectService.createOrUpdateProject(inputModel) + ); + } + + @Test + public void testCreate_activeStatusNotFound() { + // Given I am creating a new project + ProjectModel inputModel = ProjectModel.builder() + .projectName("Test Project") + .siteUnitName("Test Site") + .projectLead("Test Lead") + .build(); + + ProjectEntity savedEntity = new ProjectEntity(); + when(projectResourceAssembler.toEntity(any(ProjectModel.class))).thenReturn(savedEntity); + when(projectStatusCodeRepository.findById("ACTIVE")).thenReturn(Optional.empty()); + + // When I submit a project and the ACTIVE status doesn't exist + // Then an EntityNotFoundException should be thrown + assertThrows(ServiceException.class, () -> projectService.createOrUpdateProject(inputModel)); + verify(projectStatusCodeRepository, times(1)).findById("ACTIVE"); + verify(projectRepository, never()).saveAndFlush(any(ProjectEntity.class)); + } + + @Test + public void testCreate_violatesConstraint() { + // Given I am creating a new project with a missing required field + ProjectModel inputModel = ProjectModel.builder() + .projectGuid(UUID.randomUUID().toString()) + // Missing required siteUnitName + .projectLead("Test Lead") + .build(); + + ProjectEntity entity = new ProjectEntity(); + when(projectResourceAssembler.toEntity(any(ProjectModel.class))).thenReturn(entity); + + Set> violations = new HashSet<>(); + ConstraintViolation violation = mock(ConstraintViolation.class); + when(violation.getMessage()).thenReturn("Site unit name cannot be null"); + when(violation.getPropertyPath()).thenReturn(PathImpl.createPathFromString("siteUnitName")); + violations.add(violation); + + // Mock successful lookup of ACTIVE status + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")).thenReturn(Optional.of(activeStatus)); + + when(projectRepository.saveAndFlush(any(ProjectEntity.class))) + .thenThrow(new ConstraintViolationException("Site unit name cannot be null", violations)); + + // When/Then + assertThrows(ConstraintViolationException.class, () -> { + projectService.createOrUpdateProject(inputModel); + }); + + verify(projectRepository, times(1)).saveAndFlush(any(ProjectEntity.class)); + verify(projectResourceAssembler, times(1)).toEntity(any(ProjectModel.class)); + } + + @Test + public void testUpdate_preserveExistingStatus() { + // Given I am updating a project that has a status + ProjectStatusCodeModel existingStatus = ProjectStatusCodeModel.builder() + .projectStatusCode("DELETED") + .build(); + + ProjectModel inputModel = ProjectModel.builder() + .projectGuid(UUID.randomUUID().toString()) + .projectName("Test Project") + .siteUnitName("Test Site") + .projectLead("Test Lead") + .projectStatusCode(existingStatus) + .build(); + + ProjectStatusCodeEntity statusEntity = ProjectStatusCodeEntity.builder() + .projectStatusCode("DELETED") + .build(); + + ProjectModel returnedModel = ProjectModel.builder() + .projectGuid(inputModel.getProjectGuid()) + .projectName("Test Project") + .siteUnitName("Test Site") + .projectLead("Test Lead") + .projectStatusCode(existingStatus) + .build(); + + ProjectEntity savedEntity = new ProjectEntity(); + when(projectResourceAssembler.toEntity(any(ProjectModel.class))).thenReturn(savedEntity); + when(projectRepository.saveAndFlush(any(ProjectEntity.class))).thenReturn(savedEntity); + when(projectResourceAssembler.toModel(any(ProjectEntity.class))).thenReturn(returnedModel); + when(projectStatusCodeRepository.findById("DELETED")).thenReturn(Optional.of(statusEntity)); + + // When I update the project + ProjectModel result = projectService.createOrUpdateProject(inputModel); + + // Then the existing status should be preserved + assertEquals("DELETED", result.getProjectStatusCode().getProjectStatusCode()); + verify(projectStatusCodeRepository, never()).findById("ACTIVE"); + verify(projectStatusCodeRepository, times(1)).findById("DELETED"); + } + @Test public void test_create_new_project_with_null_guid() throws ServiceException { // Given @@ -166,6 +297,12 @@ public void test_create_new_project_with_null_guid() throws ServiceException { when(projectRepository.saveAndFlush(any(ProjectEntity.class))).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any(ProjectEntity.class))).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); + // When ProjectModel result = projectService.createOrUpdateProject(inputModel); @@ -209,6 +346,12 @@ public void test_create_new_project() throws ServiceException { when(projectRepository.saveAndFlush(any())).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any())).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); + // When ProjectModel result = projectService.createOrUpdateProject(inputModel); @@ -235,6 +378,12 @@ public void test_create_project_with_null_reference_codes() throws ServiceExcept when(projectRepository.saveAndFlush(any())).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any())).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); + // When ProjectModel result = projectService.createOrUpdateProject(inputModel); @@ -272,6 +421,12 @@ public void test_create_project_with_valid_reference_codes() throws ServiceExcep when(projectRepository.saveAndFlush(any())).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any())).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); + // When ProjectModel result = projectService.createOrUpdateProject(inputModel); @@ -298,6 +453,11 @@ public void test_update_existing_project() throws ServiceException { when(projectRepository.saveAndFlush(any())).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any())).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); // When ProjectModel result = projectService.createOrUpdateProject(inputModel); @@ -351,7 +511,7 @@ public void test_create_project_with_exception() { } @Test - public void test_create_project_with_service_exception () { + public void test_create_project_with_service_exception() { // Given ProjectModel inputModel = ProjectModel.builder() .projectName("Test Project") @@ -384,8 +544,14 @@ public void test_delete_project_with_valid_id() { when(projectResourceAssembler.toEntity(any())).thenReturn(savedEntity); when(projectRepository.saveAndFlush(any())).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any())).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); ProjectModel result = projectService.createOrUpdateProject(inputModel); + // When ProjectModel projectModel = projectService.deleteProject(existingGuid); // Then @@ -425,8 +591,14 @@ public void test_delete_project_with_exception() { when(projectResourceAssembler.toEntity(any())).thenReturn(savedEntity); when(projectRepository.saveAndFlush(any())).thenReturn(savedEntity); when(projectResourceAssembler.toModel(any())).thenReturn(inputModel); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); ProjectModel result = projectService.createOrUpdateProject(inputModel); + // When doThrow(new RuntimeException("Error deleting project")).when(projectRepository).delete(savedEntity); @@ -459,7 +631,11 @@ public void test_set_reference_entities() throws ServiceException { ProjectEntity entityToSave = new ProjectEntity(); when(projectResourceAssembler.toEntity(any())).thenReturn(entityToSave); when(projectRepository.saveAndFlush(any())).thenAnswer(i -> i.getArgument(0)); // Return what was passed in - + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); // When projectService.createOrUpdateProject(inputModel); @@ -500,6 +676,12 @@ public void test_reference_code_setters() throws ServiceException { when(projectTypeCodeRepository.findById("PTC1")).thenReturn(Optional.of(projectTypeEntity)); when(generalScopeCodeRepository.findById("GSC1")).thenReturn(Optional.of(generalScopeEntity)); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); + // Return a real entity that can have values set when(projectResourceAssembler.toEntity(any())).thenReturn(testEntity); when(projectRepository.saveAndFlush(any())).thenAnswer(i -> i.getArgument(0)); @@ -543,6 +725,12 @@ public void test_set_reference_entities_direct() throws ServiceException { when(projectRepository.saveAndFlush(any(ProjectEntity.class))) .thenAnswer(invocation -> invocation.getArgument(0)); + ProjectStatusCodeEntity activeStatus = ProjectStatusCodeEntity.builder() + .projectStatusCode("ACTIVE") + .build(); + when(projectStatusCodeRepository.findById("ACTIVE")) + .thenReturn(Optional.of(activeStatus)); + // When ProjectModel result = projectService.createOrUpdateProject(inputModel);